From 5697abaa2069dd7c32f18faf3aa4fcb7b1e4508a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:20:04 +0000 Subject: [PATCH 1/3] fix(i18n): a tenant's rewrite of an extension-contributed validation rule is served as written, on the refusal and on /meta ADR-0029 D9.2a compared a served rule message with the packaged OWNER's declaration only, so a rule a second package contributes through objectExtensions had nothing to compare with and the extension's translation won over a tenant's rewrite on both readers. - SchemaRegistry.getPackagedObjectBase: the packaged owner's declaration with each code extender's validations appended (scalars stay the owner's, #8037). The protocol's getPackagedObjectBase (/meta) and packagedRuleLookup (refusal) both read it. - The fold: an extender's rule yields to an overlay rule of the same name the owner does not declare, so the rule is served once. - Prose: i18n-resolver docblocks and content/docs/ui/translations.mdx. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cytRisTLYYDef35A9G --- ...7559-extension-rule-tenant-rewrite-wins.md | 15 +++ content/docs/ui/translations.mdx | 21 +++-- packages/metadata-protocol/src/protocol.ts | 13 ++- .../src/engine-validation-messages.ts | 25 +++-- .../engine-validation-tenant-rewrite.test.ts | 72 +++++++++++++- .../src/protocol-packaged-object-base.test.ts | 74 +++++++++++++++ packages/objectql/src/registry.ts | 93 +++++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 16 ++-- 8 files changed, 300 insertions(+), 29 deletions(-) create mode 100644 .changeset/27559-extension-rule-tenant-rewrite-wins.md 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..55802d40e54 --- /dev/null +++ b/.changeset/27559-extension-rule-tenant-rewrite-wins.md @@ -0,0 +1,15 @@ +--- +'@objectstack/objectql': minor +'@objectstack/metadata-protocol': minor +--- + +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. The fold also served the rule twice: the overlay's reworded copy, then the extension's original appended beside it. + +- **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 overlay's copy takes the rule's place.** When a tenant overlay carries a rule of the same name as an extension's rule, and the owning package does not declare that name itself, the fold serves the overlay's rule once instead of appending the extension's original beside it. A Studio edit of an extended object stores every extension rule it was served, so this is the tenant's own copy. An owner rule and an extension rule that share a name are still both served, as before. +- **`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. diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 8001e241935..96b8c2acb52 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -226,15 +226,18 @@ 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, and +the overlay's rule takes the place of the extension's rule of that name rather +than running beside it. 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..1fbc38dab99 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; and the fold lets + * the overlay's copy stand for the extender's rule rather than appending the + * original beside it, so the refusal answers that one sentence. + * * 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,51 @@ 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); }); + + it('folds the overlay copy in place of the extender rule, not beside it', () => { + const served = (engine.registry.getObject(OBJ) as any).validations; + expect(served.map((r: any) => r.name)) + .toEqual(['returned_needs_note', 'done_needs_owner', 'ext_blocked_needs_reason']); + expect(served[2].message).toBe(EXT_REWRITE); + }); + + it('serves and refuses with both rewrites as written, and still translates the untouched rule', async () => { + const rows = (t: typeof ZH) => ({ + returned_needs_note: REWRITE, + done_needs_owner: t.done_needs_owner, + ext_blocked_needs_reason: EXT_REWRITE, + }); + 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('[#27559] with the tenant overlay carrying the extension rule untouched', () => { + beforeEach(async () => { await boot(true, PACKAGED.ext_blocked_needs_reason); }); + + it('serves the extension rule once and translates it on both readers', async () => { + const served = (engine.registry.getObject(OBJ) as any).validations; + expect(served.map((r: any) => r.name)) + .toEqual(['returned_needs_note', 'done_needs_owner', 'ext_blocked_needs_reason']); + 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); }); diff --git a/packages/objectql/src/protocol-packaged-object-base.test.ts b/packages/objectql/src/protocol-packaged-object-base.test.ts index 94c45ebc7c7..64ba821b1bc 100644 --- a/packages/objectql/src/protocol-packaged-object-base.test.ts +++ b/packages/objectql/src/protocol-packaged-object-base.test.ts @@ -222,3 +222,77 @@ 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.']); + }); + + it('the fold keeps an owner rule and an extender rule of one name both, as before', () => { + // The same-name yield is the OVERLAY's: a name the packaged owner ships + // itself is two packaged rules, and the fold still serves both. + const s = makeSession(); + registerPackaged(s.registry); + s.registry.registerObject( + { name: OBJ, label: PACKAGED_LABEL, validations: [rule('shared', 'Owner says.')] } as any, + PKG, + ); + registerRuleExtension(s.registry, [rule('shared', 'Extension says.')]); + expect((s.registry.getObject(OBJ) as any).validations.map((r: any) => r.message)) + .toEqual(['Owner says.', 'Extension says.']); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 7de0224d1c5..d88503bd68a 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -600,6 +600,29 @@ function stableStringify(value: unknown): string { return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`; } +/** The `name` of each top-level rule in a `validations` list that has one. */ +function topLevelRuleNames(validations: unknown): string[] { + if (!Array.isArray(validations)) return []; + const names: string[] = []; + for (const rule of validations) { + const name = (rule as { name?: unknown } | null)?.name; + if (typeof name === 'string' && name.length > 0) names.push(name); + } + return names; +} + +/** + * [#27559] An extension body without its top-level rules of the given names — + * see {@link SchemaRegistry.tenantCarriedRuleNames}. By reference when none + * matched. + */ +function withoutRulesNamed(extension: ServiceObject, names: ReadonlySet): ServiceObject { + const list = extension.validations; + if (!Array.isArray(list)) return extension; + const kept = list.filter((rule) => !names.has((rule as { name?: unknown } | null)?.name as string)); + return kept.length === list.length ? extension : { ...extension, validations: kept }; +} + /** * Global Schema Registry * Unified storage for all metadata types (Objects, Apps, Flows, Layouts, etc.) @@ -3944,11 +3967,14 @@ export class SchemaRegistry { // access path, the claim and the assignment seam off on this object, while // the gate kept judging the extender's business column. const governQueue = resolveInjectedSystemColumns(baseDefinition).queue; + // [#27559] Computed once over the base, like `tenantAuthored` above. + const tenantRules = this.tenantCarriedRuleNames(contributors, baseDefinition); let merged = { ...baseDefinition }; for (const contrib of contributors) { if (contrib.ownership === 'extend') { let extension = governAudit ? applyAuditFieldGovernance(contrib.definition) : contrib.definition; if (governQueue) extension = this.withoutExtensionQueueField(extension, contrib.packageId); + if (tenantRules) extension = withoutRulesNamed(extension, tenantRules); merged = mergeObjectDefinitions(merged, extension, tenantAuthored); } } @@ -4202,6 +4228,35 @@ export class SchemaRegistry { * yields and the value stays exactly what the first fold produced — the same * answer, reached by yielding instead of by re-applying. */ + /** + * [#27559] The names of the rules the fold's BASE carries that the `own` + * contributor does not declare — on an overlay base, the tenant's copies of + * the extenders' rules. A Studio GET → edit → PUT stores the FOLDED object, + * so an overlay row carries every extender rule it was served, reworded or + * not; an extender's rule of such a name yields to the copy, and the fold + * serves the rule once, as the tenant last wrote it. + * + * The scalar rule ({@link tenantAuthoredScalars}) says the same for `label` / + * `description`, keyed by attribute instead of by rule name, and is + * conservative in the same ways: no `own` contributor, or a base that IS the + * owner, answers `undefined` and the fold concatenates exactly as before. A + * name the owner declares itself is not counted, so an owner rule and an + * extender rule sharing a name stay two rules, as they were. + */ + private tenantCarriedRuleNames( + contributors: ObjectContributor[], + baseDefinition: ServiceObject, + ): ReadonlySet | undefined { + const owner = contributors.find((c) => c.ownership === 'own')?.definition; + if (!owner || owner === baseDefinition || !Array.isArray(baseDefinition.validations)) return undefined; + const ownerNames = new Set(topLevelRuleNames(owner.validations)); + let names: Set | undefined; + for (const name of topLevelRuleNames(baseDefinition.validations)) { + if (!ownerNames.has(name)) (names ??= new Set()).add(name); + } + return names; + } + private tenantAuthoredScalars( contributors: ObjectContributor[], baseDefinition: ServiceObject, @@ -5074,6 +5129,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. */ From 029633fbf886f52f38225f91ac2cbf226c0dd183 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:25:24 +0000 Subject: [PATCH 2/3] docs(object-extensions): an extension rule the tenant's overlay already carries is served once Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cytRisTLYYDef35A9G --- content/docs/data-modeling/object-extensions.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/content/docs/data-modeling/object-extensions.mdx b/content/docs/data-modeling/object-extensions.mdx index e177b38acb0..a1c6cdf7605 100644 --- a/content/docs/data-modeling/object-extensions.mdx +++ b/content/docs/data-modeling/object-extensions.mdx @@ -141,8 +141,10 @@ survive a customized object rather than being dropped by it. `description` apply only while the base still carries the **packaged owner's** value. Once a tenant has renamed the object, the extension's packaged default yields and the tenant's name stands. Fields, validations and indexes are unaffected — they merge - either way. There is no escape hatch: a package cannot relabel an object a tenant has - deliberately renamed. + either way, with one exception: an extension's validation rule whose `name` the + tenant's overlay already carries, and the owner does not declare, is served once, as + the overlay has it. There is no escape hatch: a package cannot relabel an object a + tenant has deliberately renamed. --- From c8c5460d6b86e61fba18230464ef041449c793a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:50:14 +0000 Subject: [PATCH 3/3] fix(i18n): keep the validations fold additive; split the overlay-copy yield off Per contract review 6515204227: the fold yield reversed ADR-0029 D9.2a's additive validations sentence without an amendment, so it leaves this change. The one packaged rule set (getPackagedObjectBase, both readers) stays. Pins restated for the additive fold; '@objectstack/spec' patch added to the changeset for the i18n-resolver docblocks. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cytRisTLYYDef35A9G --- ...7559-extension-rule-tenant-rewrite-wins.md | 6 +- .../docs/data-modeling/object-extensions.mdx | 6 +- content/docs/ui/translations.mdx | 7 +- .../engine-validation-tenant-rewrite.test.ts | 72 +++++++++---------- .../src/protocol-packaged-object-base.test.ts | 14 ---- packages/objectql/src/registry.ts | 55 -------------- 6 files changed, 44 insertions(+), 116 deletions(-) diff --git a/.changeset/27559-extension-rule-tenant-rewrite-wins.md b/.changeset/27559-extension-rule-tenant-rewrite-wins.md index 55802d40e54..7c29d633d04 100644 --- a/.changeset/27559-extension-rule-tenant-rewrite-wins.md +++ b/.changeset/27559-extension-rule-tenant-rewrite-wins.md @@ -1,6 +1,7 @@ --- '@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) @@ -8,8 +9,9 @@ fix(i18n): a tenant's rewrite of a validation rule that an object extension cont 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. The fold also served the rule twice: the overlay's reworded copy, then the extension's original appended beside it. +#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 overlay's copy takes the rule's place.** When a tenant overlay carries a rule of the same name as an extension's rule, and the owning package does not declare that name itself, the fold serves the overlay's rule once instead of appending the extension's original beside it. A Studio edit of an extended object stores every extension rule it was served, so this is the tenant's own copy. An owner rule and an extension rule that share a name are still both served, as before. +- **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/data-modeling/object-extensions.mdx b/content/docs/data-modeling/object-extensions.mdx index a1c6cdf7605..e177b38acb0 100644 --- a/content/docs/data-modeling/object-extensions.mdx +++ b/content/docs/data-modeling/object-extensions.mdx @@ -141,10 +141,8 @@ survive a customized object rather than being dropped by it. `description` apply only while the base still carries the **packaged owner's** value. Once a tenant has renamed the object, the extension's packaged default yields and the tenant's name stands. Fields, validations and indexes are unaffected — they merge - either way, with one exception: an extension's validation rule whose `name` the - tenant's overlay already carries, and the owner does not declare, is served once, as - the overlay has it. There is no escape hatch: a package cannot relabel an object a - tenant has deliberately renamed. + either way. There is no escape hatch: a package cannot relabel an object a tenant has + deliberately renamed. --- diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 96b8c2acb52..32ab06f311d 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -231,10 +231,9 @@ with the message of the rule of the same `name` that a package shipped, a 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, and -the overlay's rule takes the place of the extension's rule of that name rather -than running beside it. 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 +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. diff --git a/packages/objectql/src/engine-validation-tenant-rewrite.test.ts b/packages/objectql/src/engine-validation-tenant-rewrite.test.ts index 1fbc38dab99..79d36e4774c 100644 --- a/packages/objectql/src/engine-validation-tenant-rewrite.test.ts +++ b/packages/objectql/src/engine-validation-tenant-rewrite.test.ts @@ -18,9 +18,9 @@ * #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; and the fold lets - * the overlay's copy stand for the extender's rule rather than appending the - * original beside it, so the refusal answers that one sentence. + * 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 — @@ -247,44 +247,42 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on const EXT_REWRITE = 'Tell us what blocks this duty.'; beforeEach(async () => { await boot(true, EXT_REWRITE); }); - it('folds the overlay copy in place of the extender rule, not beside it', () => { - const served = (engine.registry.getObject(OBJ) as any).validations; - expect(served.map((r: any) => r.name)) - .toEqual(['returned_needs_note', 'done_needs_owner', 'ext_blocked_needs_reason']); - expect(served[2].message).toBe(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 both rewrites as written, and still translates the untouched rule', async () => { - const rows = (t: typeof ZH) => ({ - returned_needs_note: REWRITE, - done_needs_owner: t.done_needs_owner, - ext_blocked_needs_reason: EXT_REWRITE, - }); - 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) }, + 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}`, + }, }); }); - }); - - describe('[#27559] with the tenant overlay carrying the extension rule untouched', () => { - beforeEach(async () => { await boot(true, PACKAGED.ext_blocked_needs_reason); }); - it('serves the extension rule once and translates it on both readers', async () => { - const served = (engine.registry.getObject(OBJ) as any).validations; - expect(served.map((r: any) => r.name)) - .toEqual(['returned_needs_note', 'done_needs_owner', 'ext_blocked_needs_reason']); - 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) }, - }); + 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); }); }); diff --git a/packages/objectql/src/protocol-packaged-object-base.test.ts b/packages/objectql/src/protocol-packaged-object-base.test.ts index 64ba821b1bc..9b99e8c4052 100644 --- a/packages/objectql/src/protocol-packaged-object-base.test.ts +++ b/packages/objectql/src/protocol-packaged-object-base.test.ts @@ -281,18 +281,4 @@ describe('#27559 getPackagedObjectBase — the packaged RULE set is the owner pl ); expect(baseOf(s.protocol).validations.map((r: any) => r.message)).toEqual(['Extension says.']); }); - - it('the fold keeps an owner rule and an extender rule of one name both, as before', () => { - // The same-name yield is the OVERLAY's: a name the packaged owner ships - // itself is two packaged rules, and the fold still serves both. - const s = makeSession(); - registerPackaged(s.registry); - s.registry.registerObject( - { name: OBJ, label: PACKAGED_LABEL, validations: [rule('shared', 'Owner says.')] } as any, - PKG, - ); - registerRuleExtension(s.registry, [rule('shared', 'Extension says.')]); - expect((s.registry.getObject(OBJ) as any).validations.map((r: any) => r.message)) - .toEqual(['Owner says.', 'Extension says.']); - }); }); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index d88503bd68a..407921f4f7b 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -600,29 +600,6 @@ function stableStringify(value: unknown): string { return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`; } -/** The `name` of each top-level rule in a `validations` list that has one. */ -function topLevelRuleNames(validations: unknown): string[] { - if (!Array.isArray(validations)) return []; - const names: string[] = []; - for (const rule of validations) { - const name = (rule as { name?: unknown } | null)?.name; - if (typeof name === 'string' && name.length > 0) names.push(name); - } - return names; -} - -/** - * [#27559] An extension body without its top-level rules of the given names — - * see {@link SchemaRegistry.tenantCarriedRuleNames}. By reference when none - * matched. - */ -function withoutRulesNamed(extension: ServiceObject, names: ReadonlySet): ServiceObject { - const list = extension.validations; - if (!Array.isArray(list)) return extension; - const kept = list.filter((rule) => !names.has((rule as { name?: unknown } | null)?.name as string)); - return kept.length === list.length ? extension : { ...extension, validations: kept }; -} - /** * Global Schema Registry * Unified storage for all metadata types (Objects, Apps, Flows, Layouts, etc.) @@ -3967,14 +3944,11 @@ export class SchemaRegistry { // access path, the claim and the assignment seam off on this object, while // the gate kept judging the extender's business column. const governQueue = resolveInjectedSystemColumns(baseDefinition).queue; - // [#27559] Computed once over the base, like `tenantAuthored` above. - const tenantRules = this.tenantCarriedRuleNames(contributors, baseDefinition); let merged = { ...baseDefinition }; for (const contrib of contributors) { if (contrib.ownership === 'extend') { let extension = governAudit ? applyAuditFieldGovernance(contrib.definition) : contrib.definition; if (governQueue) extension = this.withoutExtensionQueueField(extension, contrib.packageId); - if (tenantRules) extension = withoutRulesNamed(extension, tenantRules); merged = mergeObjectDefinitions(merged, extension, tenantAuthored); } } @@ -4228,35 +4202,6 @@ export class SchemaRegistry { * yields and the value stays exactly what the first fold produced — the same * answer, reached by yielding instead of by re-applying. */ - /** - * [#27559] The names of the rules the fold's BASE carries that the `own` - * contributor does not declare — on an overlay base, the tenant's copies of - * the extenders' rules. A Studio GET → edit → PUT stores the FOLDED object, - * so an overlay row carries every extender rule it was served, reworded or - * not; an extender's rule of such a name yields to the copy, and the fold - * serves the rule once, as the tenant last wrote it. - * - * The scalar rule ({@link tenantAuthoredScalars}) says the same for `label` / - * `description`, keyed by attribute instead of by rule name, and is - * conservative in the same ways: no `own` contributor, or a base that IS the - * owner, answers `undefined` and the fold concatenates exactly as before. A - * name the owner declares itself is not counted, so an owner rule and an - * extender rule sharing a name stay two rules, as they were. - */ - private tenantCarriedRuleNames( - contributors: ObjectContributor[], - baseDefinition: ServiceObject, - ): ReadonlySet | undefined { - const owner = contributors.find((c) => c.ownership === 'own')?.definition; - if (!owner || owner === baseDefinition || !Array.isArray(baseDefinition.validations)) return undefined; - const ownerNames = new Set(topLevelRuleNames(owner.validations)); - let names: Set | undefined; - for (const name of topLevelRuleNames(baseDefinition.validations)) { - if (!ownerNames.has(name)) (names ??= new Set()).add(name); - } - return names; - } - private tenantAuthoredScalars( contributors: ObjectContributor[], baseDefinition: ServiceObject,