Skip to content

Commit 556f323

Browse files
fix(i18n): a tenant-rewritten validation rule message beats the packaged translation, on the refusal and on /meta (#27543)
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu74gtDHSKocA9ivK4GcE6a2)_ Fixes #27221 Clause-②: yes (widening) — the exported `scalarOverridesPackagedBase` (`@objectstack/spec/system`) accepts `'message'` as a key, and `ValidationMessageContext` gains the optional `packagedRule` (reached from `EvaluateRulesOptions.messages`); corrected by the seat per contract review 6496586363 Metadata-protocol: semantic Skills-docs: updated here — content/docs/ui/translations.mdx ("An edit beats the packaged catalog": validation rule messages), prose-only commit e4a45ed ## 维护者速读 - 改了什么:租户在 overlay 里改写了套件自带校验规则的提示语(规则名不变)后,非默认语言下拒收报错和 /meta 都会被套件翻译盖回去;现在两处都只在提示语仍等于套件原句时才套用翻译,改写过的照原样显示,没改的规则照常翻译。 - 为什么改:ADR-0029 D9.2a「显式改写胜过套件默认」在规则提示语上两个读取端都没生效,租户给自己用户改的话一换语言就变回厂商译文。 - 风险与回滚:只收窄「何时套用翻译」,不新增键、不改拒收的 code/field;套件未声明的规则(objectExtensions 带来的)照常翻译。回滚即 revert 本 PR。 - 先看:`packages/spec/src/system/i18n-resolver.ts`(translateObjectValidations 的比较)、`packages/objectql/src/validation/rule-validator.ts`(authoredRuleMessage)、`packages/objectql/src/engine-validation-messages.ts`(packaged 规则查找)。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/engine-validation-tenant-rewrite.test.ts`,预期 3 passed:zh-CN/zh/en 三个语言下 /meta 与拒收给出同一句,改写的规则是租户原句。 - 需要你做的:无,席位落地(本 PR 触 packages/spec,欠一次 head 上的契约复审)。 ## What changes ADR-0029 D9.2a, applied to validation rule messages on both of their readers, with one comparison. - **`/meta` (`translateObjectValidations`, `packages/spec/src/system/i18n-resolver.ts`).** The overlay of `validations[].message` from `objects.OBJECT._validations.RULE.message` now applies only while the served message still equals the message of the packaged rule of the same name in `TranslateDocumentOptions.packagedBase` (a `conditional` branch by its own name). The test is `valueOverridesPackagedBase`, the predicate the object's scalars, embedded list views and field descriptions already use. The docblock's "No packaged-base comparison" section, which recorded the deliberate deviation of #27202, is replaced. - **The refusal (`authoredRuleMessage`, `packages/objectql/src/validation/rule-validator.ts`).** The same rule, through the exported `scalarOverridesPackagedBase`, whose key union gains `'message'`. That widening is the only change to a `@objectstack/spec/system` export: a parameter type accepts one more literal; the behaviour for the three existing keys is unchanged, and no generated artifact moves (`api-surface/system.json` records names, `api-surface-signatures.json` records only the `define*` signatures). - **The packaged rule (`packages/objectql/src/engine-validation-messages.ts`, `record-validator.ts`, `engine.ts`).** `ValidationMessageContext` gains an optional `packagedRule(ruleName)` lookup. `validationMessageContext` fills it from `SchemaRegistry.getPackagedObjectOwner(name).definition`, the same body `/meta` hands its translator through `getPackagedObjectBase`; the index is built on the first lookup. `engine.ts` changes only the host literal of `ObjectQL#validationMessageContext` (one `registry` getter), added to the claim's file surface by the seat (6495205921). No packaged owner, or a registry that cannot answer, leaves the lookup absent, and the catalog applies as before. One rule, two readers: `/meta` and the refusal of the same write still show one sentence for one rule and locale, pinned together in `engine-validation-tenant-rewrite.test.ts`. ## Why A tenant who reworded a packaged rule's message for their own users saw it reverted to the vendor's translation as soon as a user's locale had a bundle entry, on the server's refusal and on the served form. The refusal half predates #27202; the `/meta` half followed from #27202 keeping the two readers identical (triage 6468439288: "One precedence rule, used by both readers"). ## Tests Red first (commit `7976cc1a`, tests only, on `origin/main` `57c51aab4`): - `pnpm --filter @objectstack/spec exec vitest run src/system/i18n-resolver.object-validations.test.ts`: 3 failed / 13 passed (16). The three new `/meta` cases failed; the 13 #27135 cases passed. - `pnpm --filter @objectstack/objectql exec vitest run src/validation/rule-validator.test.ts src/engine-validation-tenant-rewrite.test.ts`: 4 failed / 192 passed (196). The rewrite cases on the evaluator failed, and so did the two-reader engine case. Under `zh` the engine case read the packaged translation on BOTH readers, and under `en` it read the packaged English. The overlay sanity case and the no-overlay control passed. Final head (`cb1b6888`, `origin/main` `6892888e` merged in): - New and changed pins: `i18n-resolver.object-validations.test.ts` (four D9.2a cases: rewrite served as written, untouched rules translated, a rule the base does not declare keeps its own entry, no base means catalog as before, through `translateMetadataDocument('object')` too); `rule-validator.test.ts` (five evaluator cases, a `conditional` branch included); `engine-validation-tenant-rewrite.test.ts` (one real `SchemaRegistry` with a packaged owner, a tenant overlay and an `objectExtensions` contributor, the refusal of real writes beside `translateObject` with `getPackagedObjectBase` as `packagedBase`, for `zh-CN`, `zh` and `en`; plus a no-overlay control). - `pnpm --filter @objectstack/spec test` under the verify lock: VERDICT command-exit 0, Test Files 751 passed (751), Tests 22332 passed, 1 todo. - `pnpm --filter @objectstack/objectql test` under the verify lock: VERDICT command-exit 0, Test Files 485 passed (485), Tests 9109 passed (9109). - `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter @objectstack/objectql typecheck` (with its `check:test-typecheck`): both VERDICT command-exit 0. Ablation (negative pins). Each run uses `node scripts/ablation-replace.mjs` on the committed head `e7578942`. Every mutation was proven on disk (anchor 1 to 0, blob changed), and every restore matched the HEAD blob byte for byte. All three subjects resolve through `src` (relative imports), so no `dist` preflight applies: 1. `rule-validator.ts`: `scalarOverridesPackagedBase(messages.packagedRule?.(rule.name), 'message', rule.message)` replaced by `false`. 3 failed / 193 passed (196). The two evaluator rewrite cases went red, and so did the engine case, where only the refusal half differed from the served half. 2. `i18n-resolver.ts`: the `valueOverridesPackagedBase(packaged.get(rule.name), 'message', rule.message)` line deleted. 2 failed / 14 passed (16), the rewrite cases. 3. `engine.ts`: the host literal's `get registry()` line deleted. 1 failed / 2 passed (3), the engine case. This proves the wiring, not only the predicate. The deviation in Acceptance notes is measured too. The final tests were run against the first implementation (`1a6b3305`), which counted a rule absent from the base as authored. `engine-validation-tenant-rewrite.test.ts` failed 2 of 3. The extension package's rule answered its English sentence under `zh-CN` and `zh`, both in the overlay group and in the no-overlay control. ## Acceptance notes - **Deviation from the retriage's suggested shape (6487611040), measured.** The retriage read "A tenant rule with no packaged counterpart counts as authored." This PR does not do that: a rule the packaged owner's declaration does not carry has no packaged message to diverge from, so the catalog applies, as it does with no base at all (the predicate's own first edge). Reason: `objects.OBJECT._validations.RULE` also addresses rules an `objectExtensions` entry folds onto the object (`content/docs/protocol/kernel/i18n-standard.mdx`), the registry concatenates those rules into `validations`, and both `getPackagedObjectBase` and `getPackagedObjectOwner` answer the OWNER's declaration only. Counting an absent rule as authored served every extension package's translated rule in the source language, on both readers, with no tenant overlay at all. Measured: the engine test's extension rule, run against the first implementation (`1a6b3305`), went red in both the overlay group and the no-overlay control (zh-CN answered the English sentence); green on the final head. Unlike a list view, a rule is translated under its own name, which is why the list-view convention ("a view the base does not carry counts as diverged") is not copied here. - **Not covered, outside the declared surface:** a tenant who rewrites the message of a rule that an `objectExtensions` entry contributed still gets that package's translation (the catalog applies, as before this PR; not a regression). Covering it needs the packaged base that `/meta` receives to include code extenders' rules, i.e. a change to where `@objectstack/rest` sources `packagedBase` (`packagedObjectBaseOf` / `getPackagedObjectBase`), and the same source on the refusal side so the two readers stay identical. Left to the seat to file or not. - **One predicate; the lookup is walked twice.** The precedence test is `valueOverridesPackagedBase` on both readers (objectql reaches it through `scalarOverridesPackagedBase`). The by-name walk over the packaged `validations` (branches included, the same depth bound of 10, first name wins) exists once per package: `packagedRulesByName` in `i18n-resolver.ts` and `packagedRuleLookup` in `engine-validation-messages.ts`. Sharing it would need a new `@objectstack/spec/system` export, which regenerates `packages/spec/api-surface/system.json` and `export-origins/system.json`, outside "nothing else under packages/spec". - **`ObjectQL#validationMessageContext`'s declared return type** in `engine.ts` still names `locale` / `translate` / `objectName` only; the object it returns carries `packagedRule` too and reaches the validators unchanged. Widening the declaration is a second `engine.ts` hunk, outside the approved host-literal edit. - **In-flight intersection (grant 6488299876).** The file lists of the 22 open non-mailbox PRs read at 2026-10-29T11:27Z: none touches `i18n-resolver.ts`, `rule-validator.ts`, `record-validator.ts` or `engine-validation-messages.ts`; only #27477 touches `engine.ts` (hunks L13315–20160 per the seat; this PR's hunk is at L9548), and `git merge-tree --write-tree` of this head with #27477's head `5eb62f09` is clean. The three `pm:mailbox` PRs were not read; the Version Packages PR #26517 hits none of these files on its first 100. Since then `origin/main` gained four commits (to `d4ff1a02`, read at 12:05Z), none touching this PR's nine files; `git merge-tree --write-tree` of this head with it is clean. - **Branch name.** `claude/project-thread-i8f8j5` is the card thread's harness-pinned branch and names no issue; the claim (6495130451) is the identity record. - **Contract review owed** on this PR's head before the maintainer is asked to approve (it touches `packages/spec`, #27345). ## Verification Derived local subset on the final head `cb1b6888`: `node scripts/pm/dispatch-gates.mjs --commands`, no paths, against merge base `6892888e4`, 9 paths, 589 changed lines. Every one of the 85 commands was run and its exit code captured before any pipe. The reconciliation `--ran`: "85 derived famil(ies) accounted for — 85 run, 0 NOT-MEASURED (a DERIVED zero — all 85 recorded an exit code and none of them is 3)". Two first answered exit 3 (a prerequisite, not a verdict) and were re-run green: - `check-plugin-teardown-shape.mjs --self-test` passed after fetching its pinned fixture commit: 48 cases. - `check:dual-build-cjs-loads` passed after a full `turbo run build`: 115/115 tasks. Also run: - `pnpm --filter @objectstack/spec check:generated`: "All 14 generated artifacts are up to date". No artifact under `packages/spec` moves. - `check:dev-prereqs` and `check:skill-frame-freshness` (local-only): exit 0. Repo-wide lint and the CI-owned families are read from this PR's check-runs. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (The change is inside the existing translator module; no schema moves.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Repo-wide lint is CI's run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (`check:generated` reports none stale.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72b08ee commit 556f323

10 files changed

Lines changed: 591 additions & 15 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
---
5+
6+
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)
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: semantic
10+
11+
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.
12+
13+
- **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.
14+
- **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.
15+
- **`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.
16+
- **`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.

‎content/docs/ui/translations.mdx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,19 @@ Objects follow the same rule for their `label`, `pluralLabel` and
223223
label an [object extension](/docs/data-modeling/object-extensions) contributes,
224224
for one) is served as written.
225225

226+
An object's validation rule messages follow it too, on both places a reader
227+
sees them: the error a rejected write returns, and the rule served with the
228+
object (`/meta`, and the forms built from it). The rule's `message` is compared
229+
with the message of the rule of the same `name` in the owning package's
230+
declaration, a `conditional` rule's `then` / `otherwise` branch by its own name.
231+
An organization that overlays a packaged object and rewords a rule's message,
232+
keeping its name, gets that sentence as written in every locale; the rules it
233+
left alone are still translated, and both places always show the same sentence.
234+
Unlike a widget or a bulk action, a rule the owning package does not declare
235+
(one an object extension adds, or one the organization added) is **still
236+
translated** under its own name, `objects.<name>._validations.<rule>.message`:
237+
there is no shipped message for it to differ from.
238+
226239
## Organizing the files
227240

228241
How you lay out translation source files is an authoring convention — your

‎packages/objectql/src/engine-validation-messages.ts‎

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@
3333
* closure, which runs after `validationMessageContext` has returned and sees
3434
* whatever service `setI18nService` has installed by then, as it did.
3535
* `setI18nService` itself stays in the class: it is the field's setter.
36+
* `registry` (ADR-0029 D9.2a, the packaged rule lookup) goes as a getter too.
3637
*
3738
* This module imports the engine only as a TYPE, so there is no module cycle
3839
* at run time, and nothing re-exports it, so the published surface does not
3940
* move.
4041
*/
4142

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

4547
/**
@@ -52,22 +54,88 @@ import { objectLabelKey, resolveBundleLocale } from '@objectstack/spec/system';
5254
*/
5355
export interface ValidationMessageHost {
5456
readonly i18nService: ObjectQL['i18nService'];
57+
readonly registry: ObjectQL['registry'];
5558
negotiatedMessageLocale: ObjectQL['negotiatedMessageLocale'];
5659
}
5760

58-
/** The body of `ObjectQL#validationMessageContext`; the method's docblock there is its contract. */
61+
/**
62+
* The body of `ObjectQL#validationMessageContext`; the method's docblock there is its contract.
63+
*
64+
* [ADR-0029 D9.2a] It also carries `packagedRule` (`ValidationMessageContext`),
65+
* the lookup the rule evaluator judges a tenant's rewrite of a rule message by,
66+
* when the object has a packaged owner — see {@link packagedRuleLookup}.
67+
*/
5968
export function validationMessageContext(
60-
this: Pick<ValidationMessageHost, 'i18nService' | 'negotiatedMessageLocale'>,
69+
this: Pick<ValidationMessageHost, 'i18nService' | 'registry' | 'negotiatedMessageLocale'>,
6170
objectName: string,
6271
// Only `locale` is read — narrower than `ExecutionContext` so both the
6372
// resolved and the input-shaped envelope satisfy it.
6473
context?: { locale?: string },
65-
): { locale?: string; translate?: (key: string, locale: string, params?: Record<string, unknown>) => string; objectName: string } {
74+
): ValidationMessageContext & { objectName: string } {
6675
const t = this.i18nService?.t;
76+
const packagedRule = packagedRuleLookup(this.registry, objectName);
6777
return {
6878
objectName,
6979
locale: this.negotiatedMessageLocale(context?.locale),
7080
translate: t ? (key, locale, params) => t.call(this.i18nService, key, locale, params) : undefined,
81+
...(packagedRule !== undefined ? { packagedRule } : {}),
82+
};
83+
}
84+
85+
/**
86+
* How deep {@link packagedRuleLookup} follows `conditional` branches — the
87+
* bound `/meta`'s `translateObjectValidations` walks to, so both readers judge
88+
* the same branches.
89+
*/
90+
const MAX_VALIDATION_DEPTH = 10;
91+
92+
/**
93+
* [ADR-0029 D9.2a] The packaged (code-shipped) rule of a name on `objectName`,
94+
* for the rule evaluator's divergence test.
95+
*
96+
* The declaration is the packaged OWNER's (`getPackagedObjectOwner`, pre-fold),
97+
* the same body `/meta` hands `translateObject` as `packagedBase` through
98+
* `getPackagedObjectBase`, so both readers compare a served message against one
99+
* packaged rule. Rules are keyed by `name`, each `conditional` branch under its
100+
* own name; the first rule of a name wins, as on `/meta`.
101+
*
102+
* `undefined` — nothing inferred, the catalog applies — when no packaged owner
103+
* is registered for the object, or the registry cannot answer (a partial
104+
* double). The index is built on the first lookup, which a write that refuses
105+
* nothing never makes.
106+
*/
107+
function packagedRuleLookup(
108+
registry: ValidationMessageHost['registry'] | undefined,
109+
objectName: string,
110+
): ((ruleName: string) => unknown) | undefined {
111+
if (typeof registry?.getPackagedObjectOwner !== 'function') return undefined;
112+
let definition: unknown;
113+
try {
114+
definition = registry.getPackagedObjectOwner(objectName)?.definition;
115+
} catch {
116+
// A read over in-memory contributors must not turn a 400 into a 500.
117+
return undefined;
118+
}
119+
if (!definition || typeof definition !== 'object') return undefined;
120+
const validations = (definition as { validations?: unknown }).validations;
121+
let byName: Map<string, unknown> | undefined;
122+
return (ruleName) => {
123+
if (byName === undefined) {
124+
const index = new Map<string, unknown>();
125+
const visit = (raw: unknown, depth: number): void => {
126+
if (!raw || typeof raw !== 'object' || depth >= MAX_VALIDATION_DEPTH) return;
127+
const rule = raw as { type?: unknown; name?: unknown; then?: unknown; otherwise?: unknown };
128+
if (rule.type === 'conditional') {
129+
visit(rule.then, depth + 1);
130+
visit(rule.otherwise, depth + 1);
131+
return;
132+
}
133+
if (typeof rule.name === 'string' && rule.name.length > 0 && !index.has(rule.name)) index.set(rule.name, rule);
134+
};
135+
if (Array.isArray(validations)) for (const raw of validations) visit(raw, 0);
136+
byName = index;
137+
}
138+
return byName.get(ruleName);
71139
};
72140
}
73141

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0029 D9.2a — a tenant's rewrite of a packaged rule message beats the
5+
* packaged translation, on BOTH readers of the rule, and the two still agree.
6+
*
7+
* A packaged object declares two rules and ships their `zh-CN` translations at
8+
* `objects.<o>._validations.<rule>.message`. A tenant overlays the object
9+
* (ADR-0029 D9) and rewrites one of the two messages, keeping the rule's name.
10+
* The catalog translates the PACKAGED rule, so under `zh-CN`:
11+
*
12+
* - the rewritten rule must answer the tenant's own sentence, as written;
13+
* - the rule the tenant left alone must still answer its translation;
14+
* - a rule a second package folds on through `objectExtensions` — absent
15+
* from the owner's declaration by construction — must still answer its own
16+
* package's translation: it has no packaged message to diverge from.
17+
*
18+
* Both readers are driven off ONE real registry: the refusal of a real write
19+
* (the engine's validator), and the served object as `/meta` translates it —
20+
* `translateObject` over the resolved object, with the packaged base the
21+
* serving layer hands it (`getPackagedObjectBase`, the accessor `/meta` reads
22+
* through `packagedObjectBaseOf`). For every header they must be one string.
23+
*/
24+
25+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
26+
import { preferredLocaleFromHeader, translateObject } from '@objectstack/spec/system';
27+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
28+
import { ObjectQL } from './index.js';
29+
30+
const OBJ = 'tr_duty';
31+
const PKG = 'com.objectstack.test.tenant-rewrite';
32+
const EXT_PKG = 'com.objectstack.test.tenant-rewrite-extension';
33+
34+
const PACKAGED = {
35+
returned_needs_note: 'Say why the duty is being returned.',
36+
done_needs_owner: 'A finished duty needs an owner.',
37+
ext_blocked_needs_reason: 'Say what is blocking the duty.',
38+
};
39+
const ZH = {
40+
returned_needs_note: '请写明打回的原因。',
41+
done_needs_owner: '已完成的职责需要负责人。',
42+
ext_blocked_needs_reason: '请写明阻塞原因。',
43+
};
44+
45+
/** The extension package's own rule, folded onto the object. */
46+
const EXTENSION = {
47+
name: OBJ,
48+
validations: [{
49+
type: 'script',
50+
name: 'ext_blocked_needs_reason',
51+
message: PACKAGED.ext_blocked_needs_reason,
52+
condition: "record.status == 'blocked' && record.return_note == null",
53+
fields: ['return_note'],
54+
}],
55+
};
56+
const REWRITE = 'Tell the owner what to change before you send it back.';
57+
58+
const rules = (returnedMessage: string) => [
59+
{
60+
type: 'script',
61+
name: 'returned_needs_note',
62+
message: returnedMessage,
63+
condition: "record.status == 'returned' && record.return_note == null",
64+
fields: ['return_note'],
65+
},
66+
{
67+
type: 'script',
68+
name: 'done_needs_owner',
69+
message: PACKAGED.done_needs_owner,
70+
condition: "record.status == 'done' && record.owner == null",
71+
fields: ['owner'],
72+
},
73+
];
74+
75+
const body = (returnedMessage: string) => ({
76+
name: OBJ,
77+
label: 'Duty',
78+
fields: {
79+
status: { type: 'text', label: 'Status' },
80+
return_note: { type: 'text', label: 'Return note' },
81+
owner: { type: 'text', label: 'Owner' },
82+
},
83+
validations: rules(returnedMessage),
84+
});
85+
86+
const BUNDLE: Record<string, any> = {
87+
en: {
88+
objects: {
89+
[OBJ]: {
90+
_validations: {
91+
returned_needs_note: { message: PACKAGED.returned_needs_note },
92+
done_needs_owner: { message: PACKAGED.done_needs_owner },
93+
ext_blocked_needs_reason: { message: PACKAGED.ext_blocked_needs_reason },
94+
},
95+
},
96+
},
97+
},
98+
'zh-CN': {
99+
objects: {
100+
[OBJ]: {
101+
_validations: {
102+
returned_needs_note: { message: ZH.returned_needs_note },
103+
done_needs_owner: { message: ZH.done_needs_owner },
104+
ext_blocked_needs_reason: { message: ZH.ext_blocked_needs_reason },
105+
},
106+
},
107+
},
108+
},
109+
};
110+
111+
/** `FileI18nAdapter.t()`-shaped: the locale exactly, then the fallback, then the key echoed. */
112+
function servedI18n(bundle: Record<string, any>, fallbackLocale = 'en') {
113+
const dig = (data: unknown, key: string): unknown =>
114+
key.split('.').reduce<unknown>(
115+
(cur, part) => (cur && typeof cur === 'object' ? (cur as any)[part] : undefined),
116+
data,
117+
);
118+
return {
119+
t(key: string, locale: string): string {
120+
const exact = dig(bundle[locale], key);
121+
if (typeof exact === 'string') return exact;
122+
const fallback = dig(bundle[fallbackLocale], key);
123+
return typeof fallback === 'string' ? fallback : key;
124+
},
125+
getLocales: () => Object.keys(bundle),
126+
};
127+
}
128+
129+
function makeDriver() {
130+
const rows = new Map<string, any>();
131+
return {
132+
name: 'com.objectstack.driver.memory.tr',
133+
version: '1.0.0',
134+
async connect() { /* noop */ },
135+
async disconnect() { /* noop */ },
136+
async initObjects() { /* noop */ },
137+
async find() { return [...rows.values()]; },
138+
async findOne(_o: string, options?: any) { return rows.get(String(options?.where?.id)) ?? null; },
139+
async count() { return rows.size; },
140+
async create(_o: string, data: any) { rows.set(String(data.id), data); return data; },
141+
async update(_o: string, data: any) { return data; },
142+
async delete() { return false; },
143+
} as any;
144+
}
145+
146+
describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on /meta', () => {
147+
let engine: ObjectQL;
148+
let protocol: any;
149+
150+
/** Boot the packaged object, then (optionally) the tenant's overlay layer. */
151+
async function boot(overlay: boolean) {
152+
engine = new ObjectQL();
153+
engine.registerDriver(makeDriver(), true);
154+
await engine.init();
155+
engine.setI18nService(servedI18n(BUNDLE) as any);
156+
engine.registry.registerObject(body(PACKAGED.returned_needs_note) as any, PKG);
157+
engine.registry.registerObject(EXTENSION as any, EXT_PKG, undefined, 'extend');
158+
if (overlay) {
159+
engine.registry.registerObject({ ...body(REWRITE), _provenance: 'org' } as any, PKG, undefined, 'overlay');
160+
}
161+
protocol = new ObjectStackProtocolImplementation(engine as any);
162+
}
163+
164+
afterEach(async () => {
165+
try { await engine?.destroy(); } catch { /* noop */ }
166+
});
167+
168+
async function refusal(header: string, data: Record<string, unknown>): Promise<string> {
169+
try {
170+
await engine.insert(OBJ, data, { context: { locale: preferredLocaleFromHeader(header) } } as any);
171+
} catch (e: any) {
172+
return String(e?.fields?.[0]?.message ?? e?.message);
173+
}
174+
throw new Error('expected the write to be rejected');
175+
}
176+
177+
function served(header: string): Record<string, string> {
178+
const doc = translateObject(engine.registry.getObject(OBJ) as any, BUNDLE as any, {
179+
locale: preferredLocaleFromHeader(header),
180+
fallbackChain: ['en'],
181+
packagedBase: protocol.getPackagedObjectBase(OBJ),
182+
}) as any;
183+
return Object.fromEntries(doc.validations.map((r: any) => [r.name, r.message]));
184+
}
185+
186+
async function table() {
187+
const out: Record<string, unknown> = {};
188+
for (const header of ['zh-CN', 'zh', 'en']) {
189+
out[header] = {
190+
served: served(header),
191+
refusal: {
192+
returned_needs_note: await refusal(header, { id: 'r1', status: 'returned' }),
193+
done_needs_owner: await refusal(header, { id: 'r2', status: 'done' }),
194+
ext_blocked_needs_reason: await refusal(header, { id: 'r3', status: 'blocked' }),
195+
},
196+
};
197+
}
198+
return out;
199+
}
200+
201+
describe('with the tenant overlay', () => {
202+
beforeEach(async () => { await boot(true); });
203+
204+
it('the overlay is what both readers see, over a packaged owner that is still there', () => {
205+
const served = (engine.registry.getObject(OBJ) as any).validations.map((r: any) => r.name);
206+
expect(served).toEqual(['returned_needs_note', 'done_needs_owner', 'ext_blocked_needs_reason']);
207+
expect((engine.registry.getObject(OBJ) as any).validations[0].message).toBe(REWRITE);
208+
const packaged = protocol.getPackagedObjectBase(OBJ) as any;
209+
expect(packaged.validations.map((r: any) => r.name)).toEqual(['returned_needs_note', 'done_needs_owner']);
210+
expect(packaged.validations[0].message).toBe(PACKAGED.returned_needs_note);
211+
});
212+
213+
it('serves and refuses with the rewrite as written, and still translates the untouched and extension rules', async () => {
214+
const rows = (t: typeof ZH) => ({
215+
returned_needs_note: REWRITE,
216+
done_needs_owner: t.done_needs_owner,
217+
ext_blocked_needs_reason: t.ext_blocked_needs_reason,
218+
});
219+
expect(await table()).toEqual({
220+
'zh-CN': { served: rows(ZH), refusal: rows(ZH) },
221+
zh: { served: rows(ZH), refusal: rows(ZH) },
222+
en: { served: rows(PACKAGED), refusal: rows(PACKAGED) },
223+
});
224+
});
225+
});
226+
227+
describe('control: no overlay', () => {
228+
beforeEach(async () => { await boot(false); });
229+
230+
it('translates every rule on both readers', async () => {
231+
expect(await table()).toEqual({
232+
'zh-CN': { served: ZH, refusal: ZH },
233+
zh: { served: ZH, refusal: ZH },
234+
en: { served: PACKAGED, refusal: PACKAGED },
235+
});
236+
});
237+
});
238+
});

0 commit comments

Comments
 (0)