diff --git a/.changeset/27559-extension-rule-tenant-rewrite-wins.md b/.changeset/27559-extension-rule-tenant-rewrite-wins.md new file mode 100644 index 00000000000..7c29d633d04 --- /dev/null +++ b/.changeset/27559-extension-rule-tenant-rewrite-wins.md @@ -0,0 +1,17 @@ +--- +'@objectstack/objectql': minor +'@objectstack/metadata-protocol': minor +'@objectstack/spec': patch +--- + +fix(i18n): a tenant's rewrite of a validation rule that an object extension contributes is served as written in every locale, on the refusal and on `/meta` (#27559) + +Clause-②: yes (widening) — `SchemaRegistry` (`@objectstack/objectql`) gains the public read `getPackagedObjectBase(name)` +Metadata-protocol: semantic + +#27221 made a tenant's reworded rule message beat the package's translation, but only for rules the object's owning package declares. A rule a second package adds through `objectExtensions` was compared with nothing, so when a tenant overlaid the object and reworded that rule, a reader in any locale the extension's bundle carries still got the extension's translation, on the rejected write's error and on the rule `/meta` serves. + +- **Now.** A rule's packaged message is the message of whichever code package shipped that rule name: the owner, or a package that extends the object. Both readers compare against that one packaged rule set, so a tenant's rewrite of an extension rule is served and returned as written, and an extension rule the tenant left alone is still translated. +- **The fold is unchanged.** Validations stay additive (ADR-0029 D9.2a): when the tenant's overlay carries its reworded copy of an extension rule, the extension's own rule still runs beside it, and that copy, unchanged, is still translated. +- **`SchemaRegistry.getPackagedObjectBase(name)`** answers the packaged owner's declaration with each code extender's `validations` appended (by reference when no extender ships a rule; `undefined` where `getPackagedObjectOwner` is). Only `validations` widens: `label`, `pluralLabel` and `description` stay the owner's, so an extension's label keeps winning over the owner's catalog. `ObjectStackProtocolImplementation.getPackagedObjectBase` and the rule evaluator's packaged lookup both read it. +- **`@objectstack/spec`**: the `TranslateDocumentOptions.packagedBase` docs say what an object's packaged base now carries. No type or behaviour changes. diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 8001e241935..32ab06f311d 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -226,15 +226,17 @@ 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. +with the message of the rule of the same `name` that a package shipped, a +`conditional` rule's `then` / `otherwise` branch by its own name. That is the +owning package's declaration or, for a rule an +[object extension](/docs/data-modeling/object-extensions) adds, the extending +package's. 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 +no package ships (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 diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0bf1aa32758..cdd060944ac 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -8948,6 +8948,15 @@ export class ObjectStackProtocolImplementation implements * "does a code package ship this?" test (`isCodeArtifactBody`) applied to * the OWNER contributor alone. * + * [#27559] Its `validations` are the one exception to "owner only": the + * registry's `getPackagedObjectBase` appends the rules each CODE extender + * ships, because a rule's packaged message is whichever code layer shipped + * that rule name, and the rule evaluator's `packagedRuleLookup` reads the + * same answer, so `/meta` and the refusal judge a tenant's rewrite against + * one packaged rule set. The scalars above stay the owner's. A registry + * double that predates that method is read through + * `getPackagedObjectOwner`, the owner alone. + * * Returns `undefined` — meaning "no packaged baseline, do not infer * anything" — for a runtime/tenant-authored object (no code owner), an * unknown name, and a partial registry double. The caller's contract is @@ -8956,8 +8965,10 @@ export class ObjectStackProtocolImplementation implements getPackagedObjectBase(name: string): unknown { if (typeof name !== 'string' || name === '') return undefined; const registry = (this.engine as any)?.registry; - if (!registry || typeof registry.getPackagedObjectOwner !== 'function') return undefined; + if (!registry) return undefined; try { + if (typeof registry.getPackagedObjectBase === 'function') return registry.getPackagedObjectBase(name); + if (typeof registry.getPackagedObjectOwner !== 'function') return undefined; return registry.getPackagedObjectOwner(name)?.definition; } catch { // Same rule as the fold above: a read over in-memory contributors diff --git a/packages/objectql/src/engine-validation-messages.ts b/packages/objectql/src/engine-validation-messages.ts index ecc2c8ad7e1..55ebf28bc47 100644 --- a/packages/objectql/src/engine-validation-messages.ts +++ b/packages/objectql/src/engine-validation-messages.ts @@ -93,25 +93,32 @@ 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`. + * The rules are the registry's packaged rule set (`getPackagedObjectBase`: the + * packaged owner's declaration with its code extenders' rules appended, #27559), + * the same body `/meta` hands `translateObject` as `packagedBase` through the + * protocol's `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. + * double; one that predates `getPackagedObjectBase` is read through + * `getPackagedObjectOwner`, the owner alone). 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; + if (typeof registry?.getPackagedObjectBase === 'function') { + definition = registry.getPackagedObjectBase(objectName); + } else if (typeof registry?.getPackagedObjectOwner === 'function') { + definition = registry.getPackagedObjectOwner(objectName)?.definition; + } else { + return undefined; + } } catch { // A read over in-memory contributors must not turn a 400 into a 500. return undefined; diff --git a/packages/objectql/src/engine-validation-tenant-rewrite.test.ts b/packages/objectql/src/engine-validation-tenant-rewrite.test.ts index e57f9cdf54f..79d36e4774c 100644 --- a/packages/objectql/src/engine-validation-tenant-rewrite.test.ts +++ b/packages/objectql/src/engine-validation-tenant-rewrite.test.ts @@ -15,6 +15,13 @@ * from the owner's declaration by construction — must still answer its own * package's translation: it has no packaged message to diverge from. * + * #27559 — the same holds for a rule the second package contributes: when the + * tenant's overlay carries that rule reworded (the Studio GET → edit → PUT of + * the folded object), the packaged message to compare with is the EXTENSION + * package's, so the tenant's sentence is served as written. The fold still + * appends the extension's own copy beside it (validations stay additive, + * ADR-0029 D9.2a), and that copy, unchanged, is still translated. + * * 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 @@ -148,7 +155,7 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on let protocol: any; /** Boot the packaged object, then (optionally) the tenant's overlay layer. */ - async function boot(overlay: boolean) { + async function boot(overlay: boolean, extensionMessage?: string) { engine = new ObjectQL(); engine.registerDriver(makeDriver(), true); await engine.init(); @@ -156,7 +163,13 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on 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'); + const overlayBody = body(REWRITE); + // The overlay as a Studio round-trip of the FOLDED object stores it: the + // extension's rule rides along, here with its message reworded. + if (extensionMessage !== undefined) { + overlayBody.validations.push({ ...EXTENSION.validations[0], message: extensionMessage }); + } + engine.registry.registerObject({ ...overlayBody, _provenance: 'org' } as any, PKG, undefined, 'overlay'); } protocol = new ObjectStackProtocolImplementation(engine as any); } @@ -169,7 +182,9 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on try { await engine.insert(OBJ, data, { context: { locale: preferredLocaleFromHeader(header) } } as any); } catch (e: any) { - return String(e?.fields?.[0]?.message ?? e?.message); + // Every violation, so a rule folded in twice shows as two sentences. + const fields: Array<{ message?: unknown }> = Array.isArray(e?.fields) ? e.fields : []; + return fields.length > 0 ? fields.map((f) => String(f.message)).join(' | ') : String(e?.message); } throw new Error('expected the write to be rejected'); } @@ -205,9 +220,13 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on 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); + // [#27559] The packaged rule set is the owner's rules plus the code + // extender's — every rule a code layer ships, each with its shipped message. 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.map((r: any) => r.name)) + .toEqual(['returned_needs_note', 'done_needs_owner', 'ext_blocked_needs_reason']); expect(packaged.validations[0].message).toBe(PACKAGED.returned_needs_note); + expect(packaged.validations[2].message).toBe(PACKAGED.ext_blocked_needs_reason); }); it('serves and refuses with the rewrite as written, and still translates the untouched and extension rules', async () => { @@ -224,6 +243,49 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on }); }); + describe('[#27559] with the tenant overlay rewording the extension rule', () => { + const EXT_REWRITE = 'Tell us what blocks this duty.'; + beforeEach(async () => { await boot(true, EXT_REWRITE); }); + + /** Every served message of `ext_blocked_needs_reason`, in fold order. */ + function servedExtension(header: 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 doc.validations + .filter((r: any) => r.name === 'ext_blocked_needs_reason') + .map((r: any) => r.message); + } + + it('serves and refuses with the rewrite as written, and still translates the extension copy beside it', async () => { + // The fold is additive for validations: the overlay's reworded copy, then + // the extension's own rule. Only the reworded one diverges from the + // extension package's packaged message. + const out: Record = {}; + for (const header of ['zh-CN', 'zh', 'en']) { + out[header] = { + served: servedExtension(header), + refusal: await refusal(header, { id: 'r3', status: 'blocked' }), + }; + } + expect(out).toEqual({ + 'zh-CN': { served: [EXT_REWRITE, ZH.ext_blocked_needs_reason], refusal: `${EXT_REWRITE} | ${ZH.ext_blocked_needs_reason}` }, + zh: { served: [EXT_REWRITE, ZH.ext_blocked_needs_reason], refusal: `${EXT_REWRITE} | ${ZH.ext_blocked_needs_reason}` }, + en: { + served: [EXT_REWRITE, PACKAGED.ext_blocked_needs_reason], + refusal: `${EXT_REWRITE} | ${PACKAGED.ext_blocked_needs_reason}`, + }, + }); + }); + + it('leaves the owner rules as #27543 answers them', async () => { + expect(await refusal('zh-CN', { id: 'r1', status: 'returned' })).toBe(REWRITE); + expect(await refusal('zh-CN', { id: 'r2', status: 'done' })).toBe(ZH.done_needs_owner); + }); + }); + describe('control: no overlay', () => { beforeEach(async () => { await boot(false); }); diff --git a/packages/objectql/src/protocol-packaged-object-base.test.ts b/packages/objectql/src/protocol-packaged-object-base.test.ts index 94c45ebc7c7..9b99e8c4052 100644 --- a/packages/objectql/src/protocol-packaged-object-base.test.ts +++ b/packages/objectql/src/protocol-packaged-object-base.test.ts @@ -222,3 +222,63 @@ describe('#8284 getPackagedObjectBase — the packaged declaration, pre-fold', ( expect(noRegistry.getPackagedObjectBase(OBJ)).toBeUndefined(); }); }); + +describe('#27559 getPackagedObjectBase — the packaged RULE set is the owner plus its code extenders', () => { + const EXT_PKG = 'app.showcase-success'; + const rule = (name: string, message: string) => ({ + type: 'script', name, message, condition: 'record.name == null', fields: ['name'], + }); + + function registerRuleExtension(registry: SchemaRegistry, rules: unknown[], packageId = EXT_PKG) { + registry.registerObject( + { name: OBJ, label: EXTENSION_LABEL, validations: rules } as any, + packageId, + undefined, + 'extend', + 210, + ); + } + + it('appends each code extender\'s rules to the owner\'s, and keeps the owner\'s scalars (#8037)', () => { + const s = makeSession(); + registerPackaged(s.registry); + s.registry.registerObject( + { name: OBJ, label: 'Ignored', validations: [rule('own_rule', 'Owner says.')] } as any, + PKG, + ); + registerRuleExtension(s.registry, [rule('ext_rule', 'Extension says.')]); + + const base = baseOf(s.protocol); + expect(base.validations.map((r: any) => [r.name, r.message])).toEqual([ + ['own_rule', 'Owner says.'], + ['ext_rule', 'Extension says.'], + ]); + // ⛔ Not by widening the object scalars' base: the extension's label must + // still read as diverged from the packaged one, so it keeps winning. + expect(base.label).toBe('Ignored'); + expect((s.registry.getObject(OBJ) as any).label).toBe(EXTENSION_LABEL); + }); + + it('stays the owner declaration BY REFERENCE when no extender ships a rule', () => { + const s = makeSession(); + registerPackaged(s.registry); + registerExtension(s.registry); + expect(baseOf(s.protocol)).toBe(s.registry.getPackagedObjectOwner(OBJ)?.definition); + }); + + it('does not move under a tenant overlay that rewords an extender rule', () => { + const s = makeSession(); + registerPackaged(s.registry); + registerRuleExtension(s.registry, [rule('ext_rule', 'Extension says.')]); + s.registry.registerObject( + { + name: OBJ, label: 'Customer', validations: [rule('ext_rule', 'Tenant says.')], + _packageId: 'sys_metadata', _provenance: 'org', + } as any, + 'sys_metadata', + undefined, + 'overlay', + ); + expect(baseOf(s.protocol).validations.map((r: any) => r.message)).toEqual(['Extension says.']); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 7de0224d1c5..407921f4f7b 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -5074,6 +5074,44 @@ export class SchemaRegistry { return owner && isCodeArtifactBody(owner.definition) ? owner : undefined; } + /** + * [ADR-0029 D9.2a, #27559] The packaged base a served object is compared + * against: the {@link getPackagedObjectOwner} declaration, with the + * `validations` of every CODE `extend` contributor appended in fold order. + * + * The packaged message of a rule is the message of whichever code layer + * ships that rule name — the owner or a code extender — because the catalog + * translates exactly what those layers ship. Reading the owner alone left an + * extender's rule with nothing to compare a tenant's rewrite with, so the + * catalog won over it on `/meta` and on the refusal alike. Both readers take + * their packaged rules from here (`getPackagedObjectBase` in + * `@objectstack/metadata-protocol`, `packagedRuleLookup` in this package), + * so they cannot split again. + * + * ⛔ ONLY `validations` widens. The scalars stay the owner's: an extension's + * `label` / `description` must keep reading as diverged from the packaged + * one, or the catalog takes back the case #8037 filed. + * + * Returns the owner's definition BY REFERENCE when no code extender ships a + * rule, and `undefined` exactly where {@link getPackagedObjectOwner} does. + */ + getPackagedObjectBase(name: string): ServiceObject | undefined { + const owner = this.getPackagedObjectOwner(name); + if (!owner) return undefined; + const fqn = this.resolveObjectKey(name); + let rules: unknown[] | undefined; + for (const contrib of (fqn === undefined ? undefined : this.objectContributors.get(fqn)) ?? []) { + if (contrib.ownership !== 'extend' || !isCodeArtifactBody(contrib.definition)) continue; + const list = contrib.definition.validations; + if (!Array.isArray(list) || list.length === 0) continue; + rules ??= [...(owner.definition.validations ?? [])]; + rules.push(...list); + } + return rules === undefined + ? owner.definition + : { ...owner.definition, validations: rules as ServiceObject['validations'] }; + } + /** * [ADR-0029 D9.7] Remove the tenant OVERLAY layer of an object, leaving the * packaged owner exactly where it is — the layer-addressed sibling of diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 0d0d431f933..da933e0cd48 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -344,7 +344,10 @@ export interface TranslateDocumentOptions extends ResolveOptions { /** * The PACKAGED (code-layer) base document this one was resolved from — the * owner's own declaration, BEFORE `objectExtensions` are folded on - * (ADR-0029 D9.2) and before any tenant `sys_metadata` overlay. + * (ADR-0029 D9.2) and before any tenant `sys_metadata` overlay. For an + * object, its `validations` also carry the rules each code extender ships + * (#27559), since a rule's packaged message is the message of whichever code + * layer shipped that rule name; its scalars stay the owner's. * * Supplied by the serving layer, which is the only one that knows it; when * it is absent the translators behave exactly as they did before #8284 (the @@ -4008,13 +4011,14 @@ const MAX_VALIDATION_DEPTH = 10; * 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. + * translated under its OWN name. A rule an `objectExtensions` entry folds onto + * the object is in the base too (#27559): the serving layer appends each code + * extender's rules to the owner's, so a tenant's rewrite of it is judged + * against the extension package's message, and the untouched rule is + * translated by that package's entry for its name. * * The evaluator applies the same rule to its refusal, through the exported - * {@link scalarOverridesPackagedBase} and the packaged owner's declaration the + * {@link scalarOverridesPackagedBase} and the same packaged rule set the * engine hands it, so `/meta` and the refusal of the same write still show one * sentence for one rule and locale. */