Skip to content

Commit 45efba2

Browse files
fix(metadata-protocol)!: an object_extension item's refusals fit the item door, and its target is the key the object is registered under (#28834) (#28853)
Fixes #28834 <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XT6ZDDQFYhmZ6kxDp4hSdwM)_ Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — no file under `skills/**` or `content/docs` quotes either override refusal sentence or teaches an extension's `extend` spelling against a legacy `ns__name` key; `skills/objectstack-data/SKILL.md` teaches `objectExtensions` by a plain object name, which stays admitted. ## 维护者速读 - **改了什么**:① 给 `object_extension` 带 `overrides` 时的拒绝句改成「扩展不接受覆盖,名字就是它扩展的对象,请直接改这个扩展」,不再叫人「换个新名字另建」;给 `object` 带 `overrides` 的拒绝句在 manifest 的 `objectExtensions` 之外,也点出 `/api/v1/meta/object_extension` 这个保存口。② 保存/发布/回滚口拒绝 `extend` 写成短名、但注册表实际挂在旧式 `ns__name` 键上的扩展(以前会再开一个同短名的幽灵对象),拒绝句写出两种拼法。 - **为什么改**:PR #28811 合约评审的 should-fix 1、4(卡 #28834)。第 3 项(spec 注册行注释)在 `packages/spec/**`,按裁决留给 spec 席位,本 PR 不碰。 - **风险与回滚**:拒绝码和状态不变(`NOT_OVERRIDABLE`/403、`VALIDATION_ERROR`/400);其他类型的拒绝句逐字不变(permission 对照钉住)。收窄只命中带 `__` 的旧键,实测本仓库与 hotcrm 的 manifest 和对象都没有。回滚 = revert 本 PR。 - **先看**:`packages/metadata-protocol/src/protocol-packaged-base-refusal.ts` 的 `judgeOverrideWrite` 两处句子;`packages/metadata-protocol/src/protocol-object-extension-item.ts` 第 4 步新加的拼法判断。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-object-extension-item-door-fit.test.ts`,预期 5 个测试全绿。 - **需要你做的**:无,席位安排合约评审并落地。 ## What changes - **`declared_type_not_overridable` for `object_extension`** (`judgeOverrideWrite`, `protocol-packaged-base-refusal.ts`). A new branch ahead of the D6 sentence, for this type only. The new sentence: `` 'object_extension/NAME' carries `overrides`, but an object extension takes no override (ADR-0146 D10): it adds to the object it extends and is stored under that object's name. Remove `overrides` and edit the object_extension 'NAME' itself. `` The rule name, code and status stay. - **`declared_object`** names both places an extension is written: `` ... add the change as an object extension in the base's paired .custom package: an `object_extension` item saved through `/api/v1/meta/object_extension`, or an `objectExtensions` entry of its manifest. `` The rule, code and status stay. This sentence is also what `os validate` and the install door print, so it names the manifest entry as well as the item door. - **The target spelling** (`objectExtensionItemRefusal` step 4, `protocol-object-extension-item.ts`). After `getObject(extend)` resolves, the gate refuses with `VALIDATION_ERROR` / 400 on `extend` when the resolved object's `name` (the key it is registered under, since `computeFQN` is the identity) differs from `extend`. The sentence names both spellings and says to save it under the registered key. The gate is the same one that save, per-item publish and history restore already call. ⛔ The manifest loop in `objectql/src/engine.ts` and `computeFQN` are untouched. - `.changeset/28834-object-extension-door-fit.md`: `minor` on `@objectstack/metadata-protocol`, both declaration lines, the ADR-0087 `not-required (no-migration-prescription)` marker and the BREAKING banner. - No new rule, no new `OverrideWriteRule` member, no new error code, no new gate, nothing under `packages/spec/**`. ## Measurement before the narrowing (Wanted 3) - **Shipped manifests.** The non-test `objectExtensions` in this repository are `plugin-auth` (`extend: 'sys_sso_provider'`) and the showcase (`extend: 'showcase_account'`). Each resolves to the same key, so neither is refused. `hotcrm` has no `objectExtensions` and no `extend:`. - **Legacy keys.** No non-test object in this repository or in `hotcrm` is named `ns__name`. The only `extend` with `__` in the tree is a spec/lint test fixture (`cbpx__cbpx_line`), which names the key itself and so is admitted. - **Stored rows.** I have no deployed store to read. The item door these rows come through landed in #28574 (`0c1829a28a`), the change just before this one. The boot and read hydrations register stored rows through `registrableObjectExtension` and never call this gate, so a stored row with that spelling is not refused at rest. Only a re-save of it is. No live row was found that this would refuse, so the order's stop condition did not trigger. ## Tests `packages/runtime/src/domains/meta-object-extension-item-door-fit.test.ts` (new, 5 tests). It uses the rig of `meta-object-extension-item.test.ts`: a real better-sqlite3 `SqlDriver`, a real `ObjectQL` engine and the real protocol. The base `com.acme.crm` is registered through `registerApp`, owns `crm_account` and the legacy-keyed `crm__case`, and its pair is installed through `installPackage`. Every refusal asserts its envelope, its whole sentence and that no row was left. | Reading | Result | |---|---| | Head `461552ac` | `Tests 5 passed (5)`; with the #28574 file, 24/24 | | Base `0c1829a2` (both production files restored, the test kept, metadata-protocol rebuilt without the turbo cache) | `Tests 3 failed, 2 passed (5)`. Red: the extension sentence (base said `a object_extension takes no override (ADR-0146 D6) ... create your own object_extension under a new name`), the object sentence (no item door), the folded spelling (admitted, `null`). Green: the permission control and the registered-key control. | The tests: - the object_extension sentence, exactly, and not "new name" / "new similar"; - the object sentence, exactly; - control: `permission`'s D6 sentence, byte for byte; - the folded short name `case` refused in draft and in publish mode, with the issue on `extend`, no row left, and no contributor under `case`; - control: `crm__case` admitted and published, the pair as its `extend` contributor, `region__c` on the object. <details><summary>Ablations (<code>node scripts/ablation-replace.mjs</code> from committed HEAD, metadata-protocol rebuilt without the cache, marker counted in dist)</summary> - **The extension sentence branch.** `if (type === OBJECT_EXTENSION_TYPE) {` guarded off (marker `ABL_A`, 1 in dist). `Tests 1 failed, 4 passed`: exactly the extension sentence. Restored: blob equals HEAD, `git diff HEAD` empty. - **The item door in the object sentence.** `${OBJECT_EXTENSION_ITEM_DOOR}, or` replaced (marker `ABL_B`). `Tests 1 failed, 4 passed`: exactly the object sentence. Restored clean. The first attempt dropped the constant entirely, so the DTS build refused an unused local (exit 9, nothing measured). It was re-run with the constant kept as `.slice(0, 0)`. - **The spelling check.** Its condition guarded off (marker `ABL_C`). `Tests 1 failed, 4 passed`: exactly the folded-spelling test. Restored clean. - After the last leg, metadata-protocol was rebuilt at HEAD: 0 markers in dist, and the card files are 24/24. </details> <details><summary>Suites, typecheck, lint and gates at head</summary> | Run | Result | |---|---| | `@objectstack/metadata-protocol` | 297 files passed, 4 skipped · 22090 tests passed, 26 skipped | | `@objectstack/objectql` | 522 files · 10009 tests | | `@objectstack/runtime` | 514 files · 9261 tests passed, 19 skipped | | `pnpm --filter @objectstack/metadata-protocol typecheck` | exit 0 | | `pnpm --filter @objectstack/runtime typecheck` (incl. `check:test-typecheck`) | exit 0. The first run was exit 1 on `organization-ownership-boot-guard.turso-remote.kernel.test.ts` TS2307 because `@objectstack/driver-turso` had no `dist/` in the fresh worktree; after building it, exit 0 with the ledger unchanged. | | `eslint --no-inline-config` over the three changed TS files | exit 0 | | `check:type-check-debt` | exit 0 | | `dispatch-gates.mjs --commands` (61 families) | all 61 exit 0 after a full `pnpm build`; `--ran`: 61 run, 0 NOT-MEASURED. The changeset and citation families were re-run after the last amend: exit 0. No gate added. | | Full dogfood (narrowing arm), head `461552ac` | VERDICT command-exit 1 · Test Files 4 failed, 376 passed, 3 skipped (383) · Tests 4 failed, 3115 passed, 28 skipped (3147). The four reds are the 18.0-upgrade files `audit-key-material`, `audit-mail-bodies`, `identity-mail-read-gate` and `upgrade-18x-harness` (`no such table: sys_activity__r20261019`, rewrite counts halved). The same four files on base `0c1829a2`, with metadata-protocol rebuilt at base: the same 4 red, 45 passed. So they are on main, tracked by #28849, and not this diff. | </details> ## Acceptance notes - **Wanted 2 is not here.** The `object_extension` registry row's comment is in `packages/spec/src/kernel/metadata-plugin.zod.ts`, which the ruling sends to the spec seat. It is untouched and left for that seat. The PR #28811 body's probe line `objects: []` (the export omits the key) is on a merged PR, so it is noted here rather than edited. - **Why `name` is the resolved key.** `registerObject` files an object under `computeFQN(namespace, schema.name)`, which is the identity, so the merged object's `name` is its key. A target named by its own key, legacy or not, is unchanged. - **Branch order.** The extension branch comes after the D10 `object` branch and before the D6 disposition check. `object_extension` is classified `excluded`, so before this change it reached the D6 sentence. ## Verification `pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-object-extension-item-door-fit.test.ts` gives 5/5. The same command on base `0c1829a2`, with metadata-protocol rebuilt, gives 3 red, as in the table. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5kkSYvKhs52WGfTj3c Co-authored-by: Claude <noreply@anthropic.com>
1 parent 67103cc commit 45efba2

4 files changed

Lines changed: 253 additions & 2 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
fix(metadata-protocol)!: an `object_extension` item's refusals fit the item door, and an extension names its target by the key the object is registered under (#28834)
6+
7+
Clause-②: no (narrowing) — the save, publish and restore doors refuse an `object_extension` whose `extend` is a short name the registry folds to a different, legacy `<ns>__<name>` key, which they admitted before. Nothing is widened.
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes. One spelling of an existing key's value is refused at the item doors, with a sentence naming the key the object is registered under; a manifest's objectExtensions and stored rows at rest are untouched, so there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the `object_extension` item doors (`@objectstack/metadata-protocol`).
13+
14+
- **Before.** `getObject` resolves a short name to a legacy `<ns>__<name>` key (a key `computeFQN` no longer produces, so only an object whose name carries `__` has one). The gate checked that the target resolved, and the registry then filed the extension under the short name it was written with: a second, phantom object beside the real one.
15+
- **Now.** The gate refuses that write with `VALIDATION_ERROR` / 400 on `extend`, and the sentence names both spellings. Save the extension under the key the object is registered as, with `extend` set to it.
16+
- **Who sees it.** No one measured: no manifest or object in this repository or in `hotcrm` has a `__` key, the item door itself landed one change earlier (#28574), and the boot hydration does not run this gate, so a stored row of that spelling is not refused at rest.
17+
18+
Two refusal sentences change wording. Their rule, code and status stay (`NOT_OVERRIDABLE` / 403):
19+
20+
- An `object_extension` carrying `overrides` is told an extension takes no override and is stored under the name of the object it extends, so the fix is to edit the extension itself. It is no longer told to create one "under a new name".
21+
- An `object` carrying `overrides` is told both places an extension is written: an `object_extension` item saved through `/api/v1/meta/object_extension`, or an `objectExtensions` entry of the pair's manifest.
22+
23+
Every other type's override sentence is unchanged.

‎packages/metadata-protocol/src/protocol-object-extension-item.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,11 @@ export interface ObjectExtensionItemSubject {
138138
* would refuse the key with the generic unknown-key sentence, so the
139139
* author reads the reason a sync would otherwise throw at registration.
140140
* 3. The row name is the target: `extend` equals the name it is filed under.
141-
* 4. The target object is registered.
141+
* 4. The target object is registered, under the very key `extend` spells
142+
* (#28834): `getObject` also folds a short name to a legacy
143+
* `<ns>__<name>` key, while the registry files an extension under the
144+
* name it is written with, so a folded spelling would open a second,
145+
* phantom object beside the one it meant.
142146
* 5. The target lies inside the written package's dependency closure — the
143147
* package's own objects, its declared dependencies' and platform objects
144148
* (`narrowObjectsToPackageClosure`, the one predicate), refused with the
@@ -195,6 +199,14 @@ export function objectExtensionItemRefusal(
195199
'extend',
196200
);
197201
}
202+
if (typeof object.name === 'string' && object.name !== target) {
203+
return refusal(
204+
`Object extension '${name}' extends '${target}', but that object is registered as '${object.name}': `
205+
+ `an extension is filed under the name it is written with, so '${target}' would open a second object `
206+
+ `beside '${object.name}'. Save it as '${object.name}', with \`extend\` set to '${object.name}'.`,
207+
'extend',
208+
);
209+
}
198210
if (narrowObjectsToPackageClosure([object], scope).length === 0) {
199211
return refusal(
200212
`Object extension '${name}' in package '${binding}': object '${target}' is outside the package's `

‎packages/metadata-protocol/src/protocol-packaged-base-refusal.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import type { ObjectStackProtocolImplementation } from './protocol.js';
3939
import { isOriginGatedType, managedItemSealedSentence, regimeCDeclaredSaveSentence } from './packaged-base-regime.js';
4040
import { SysMetadataRepository } from './sys-metadata-repository.js';
41+
import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js';
4142
import {
4243
customizationBasePackageId,
4344
customizationPackageId,
@@ -330,6 +331,9 @@ export interface OverrideWriteClosure {
330331
readonly shippers: readonly string[];
331332
}
332333

334+
/** [#28834] The item door an `object_extension` is saved through (#28574), as the refusals name it. */
335+
const OBJECT_EXTENSION_ITEM_DOOR = '`/api/v1/meta/object_extension`';
336+
333337
/** Which named refusal of ADR-0146 D2 / D6 / D9 / D10 a write meets. */
334338
export type OverrideWriteRule =
335339
| 'undeclared_same_name'
@@ -411,9 +415,20 @@ export function judgeOverrideWrite(
411415
if (declaration.present) {
412416
const row = getOverrideTypeClassification(type);
413417
if (row?.disposition === 'extend_only') {
418+
// [#28834] Both doors an extension is written through: the item
419+
// door (#28574) and the manifest's collection.
414420
return refuse('declared_object',
415421
`${item} carries \`overrides\`, but an object is extended, never replaced (ADR-0146 D10). `
416-
+ 'Remove `overrides` and add the change as an `objectExtensions` entry of the base\'s paired .custom package.');
422+
+ 'Remove `overrides` and add the change as an object extension in the base\'s paired .custom package: '
423+
+ `an \`object_extension\` item saved through ${OBJECT_EXTENSION_ITEM_DOOR}, or an \`objectExtensions\` entry of its manifest.`);
424+
}
425+
if (type === OBJECT_EXTENSION_TYPE) {
426+
// [#28834, ADR-0146 D10] An extension takes no override and gets
427+
// no new name: its name is the object it extends, so the D6
428+
// "new similar" remedy below would name a write that cannot exist.
429+
return refuse('declared_type_not_overridable',
430+
`${item} carries \`overrides\`, but an object extension takes no override (ADR-0146 D10): it adds to the `
431+
+ `object it extends and is stored under that object's name. Remove \`overrides\` and edit the object_extension '${name}' itself.`);
417432
}
418433
if (row?.disposition !== 'overridable') {
419434
return refuse('declared_type_not_overridable',
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28834, ADR-0146 D10] An `object_extension` item's refusals fit the item
5+
* door #28574 opened.
6+
*
7+
* The business case: an administrator adds "Region" to the vendor's Account
8+
* through Setup, which saves an `object_extension` item into the base's
9+
* `.custom` pair.
10+
*
11+
* 1. Carrying `overrides`, the save is refused (`declared_type_not_overridable`)
12+
* with a sentence that fits an extension: it takes no override and has no
13+
* new name, so the remedy is to edit the extension itself. An `object`
14+
* carrying `overrides` (`declared_object`) is told both doors an
15+
* extension is written through: the item door and the manifest entry.
16+
* Every other type's sentence is unchanged (the permission control).
17+
* 2. A short `extend` that `getObject` folds to a legacy `<ns>__<name>` key
18+
* is refused: the registry files an extension under the name it is
19+
* written with, so it would open a second, phantom object. The key the
20+
* object is registered under is still admitted (the control).
21+
*
22+
* ## The rig
23+
*
24+
* The rig of `meta-object-extension-item.test.ts`: better-sqlite3 `:memory:`
25+
* behind the real `ObjectQL` engine and a real
26+
* `ObjectStackProtocolImplementation` over its `SchemaRegistry`. The base is a
27+
* code package registered through `registerApp`; the pair is installed
28+
* through the protocol's package door. Every refusal asserts its envelope, its
29+
* sentence and that no row was left.
30+
*/
31+
32+
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
33+
import { mkdtempSync, rmSync } from 'node:fs';
34+
import { tmpdir } from 'node:os';
35+
import { join } from 'node:path';
36+
import { ObjectQL } from '@objectstack/objectql';
37+
import { SqlDriver } from '@objectstack/driver-sql';
38+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
39+
import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata';
40+
41+
let home: string;
42+
let priorHome: string | undefined;
43+
beforeAll(() => {
44+
priorHome = process.env.OS_HOME;
45+
home = mkdtempSync(join(tmpdir(), 'os-object-extension-door-fit-'));
46+
process.env.OS_HOME = home;
47+
});
48+
afterAll(() => {
49+
if (priorHome === undefined) delete process.env.OS_HOME;
50+
else process.env.OS_HOME = priorHome;
51+
rmSync(home, { recursive: true, force: true });
52+
});
53+
54+
const TEST_PACKAGE_ID = 'objectstack-test';
55+
const BASE = 'com.acme.crm';
56+
const PAIR = 'com.acme.crm.custom';
57+
const TARGET = 'crm_account';
58+
/** An object the base ships under a legacy `<ns>__<name>` key: `getObject('case')` folds to it. */
59+
const LEGACY_KEY = 'crm__case';
60+
const LEGACY_SHORT = 'case';
61+
const DECLARATION = { package: BASE, basedOn: '3.4.1' };
62+
63+
const baseManifest = () => ({
64+
id: BASE,
65+
name: 'crm',
66+
version: '3.4.1',
67+
type: 'app',
68+
namespace: 'crm',
69+
objects: [
70+
{ name: TARGET, label: 'Account', fields: { name: { name: 'name', label: 'Name', type: 'text' } } },
71+
{ name: LEGACY_KEY, label: 'Case', fields: { subject: { name: 'subject', label: 'Subject', type: 'text' } } },
72+
],
73+
});
74+
75+
const region = (extend: string, extra: Record<string, unknown> = {}) => ({
76+
extend,
77+
fields: { region__c: { type: 'text', label: 'Region' } },
78+
...extra,
79+
});
80+
81+
const liveEngines: ObjectQL[] = [];
82+
afterEach(async () => {
83+
while (liveEngines.length) {
84+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
85+
}
86+
});
87+
88+
async function boot() {
89+
const engine = new ObjectQL();
90+
liveEngines.push(engine);
91+
engine.registerDriver(new SqlDriver({
92+
client: 'better-sqlite3',
93+
connection: { filename: ':memory:' },
94+
useNullAsDefault: true,
95+
}), true);
96+
await engine.init();
97+
engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID);
98+
engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID);
99+
engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID);
100+
(engine.registry as unknown as { logLevel: string }).logLevel = 'silent';
101+
engine.registerApp(baseManifest());
102+
await engine.syncSchemas();
103+
104+
const protocol = new ObjectStackProtocolImplementation(engine as any, () => new Map());
105+
const p = protocol as any;
106+
await p.installPackage({
107+
manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0', type: 'app', namespace: 'crm_custom', dependencies: { [BASE]: '^3' } },
108+
});
109+
110+
/** Every stored row, read back from the store. */
111+
const rows = async () => ((await engine.find('sys_metadata', { where: {}, context: { isSystem: true } })) ?? [])
112+
.filter((r: any) => r.package_id === PAIR)
113+
.map((r: any) => ({ type: r.type, name: r.name, state: r.state }));
114+
const thrown = (run: Promise<unknown>) => run.then(() => undefined, (e: any) => e);
115+
return { engine, p, rows, thrown };
116+
}
117+
118+
const envelope = (e: any) => (e ? { code: e.code, status: e.status } : null);
119+
const REFUSED = { code: 'NOT_OVERRIDABLE', status: 403 };
120+
121+
describe('an object_extension item\'s override refusals fit the item door (#28834)', () => {
122+
it('`overrides` on an object_extension is refused with the edit-the-extension sentence', async () => {
123+
const { p, rows, thrown } = await boot();
124+
const e = await thrown(p.saveMetaItem({
125+
type: 'object_extension', name: TARGET, packageId: PAIR, item: region(TARGET, { overrides: DECLARATION }),
126+
}));
127+
expect(envelope(e)).toEqual(REFUSED);
128+
expect(e.message).toBe(
129+
`'object_extension/${TARGET}' carries \`overrides\`, but an object extension takes no override (ADR-0146 D10): `
130+
+ 'it adds to the object it extends and is stored under that object\'s name. '
131+
+ `Remove \`overrides\` and edit the object_extension '${TARGET}' itself.`,
132+
);
133+
expect(e.message).not.toMatch(/new name|new similar/);
134+
expect(await rows()).toEqual([]);
135+
});
136+
137+
it('`overrides` on an object is refused naming the item door beside the manifest entry', async () => {
138+
const { p, rows, thrown } = await boot();
139+
const e = await thrown(p.saveMetaItem({
140+
type: 'object', name: TARGET, packageId: PAIR,
141+
item: { name: TARGET, label: 'Account', fields: {}, overrides: DECLARATION },
142+
}));
143+
expect(envelope(e)).toEqual(REFUSED);
144+
expect(e.message).toBe(
145+
`'object/${TARGET}' carries \`overrides\`, but an object is extended, never replaced (ADR-0146 D10). `
146+
+ 'Remove `overrides` and add the change as an object extension in the base\'s paired .custom package: '
147+
+ 'an `object_extension` item saved through `/api/v1/meta/object_extension`, or an `objectExtensions` entry of its manifest.',
148+
);
149+
expect(await rows()).toEqual([]);
150+
});
151+
152+
it('control: another excluded type keeps its D6 sentence byte for byte', async () => {
153+
const { p, rows, thrown } = await boot();
154+
const e = await thrown(p.saveMetaItem({
155+
type: 'permission', name: 'crm_perm', packageId: PAIR,
156+
item: { name: 'crm_perm', label: 'CRM', overrides: DECLARATION },
157+
}));
158+
expect(envelope(e)).toEqual(REFUSED);
159+
expect(e.message).toBe(
160+
'\'permission/crm_perm\' carries `overrides`, but a permission takes no override (ADR-0146 D6). '
161+
+ 'Remove `overrides` and create your own permission under a new name ("new similar").',
162+
);
163+
expect(await rows()).toEqual([]);
164+
});
165+
});
166+
167+
describe('an object_extension item names its target by the key it is registered under (#28834)', () => {
168+
it('a short name getObject folds to a legacy key is refused, naming both spellings, and opens no phantom object', async () => {
169+
const { engine, p, rows, thrown } = await boot();
170+
// The fold this refuses: the short name reaches the legacy entry.
171+
expect((engine.registry.getObject(LEGACY_SHORT) as any)?.name).toBe(LEGACY_KEY);
172+
173+
for (const mode of ['draft', undefined] as const) {
174+
const e = await thrown(p.saveMetaItem({
175+
type: 'object_extension', name: LEGACY_SHORT, packageId: PAIR, item: region(LEGACY_SHORT),
176+
...(mode ? { mode } : {}),
177+
}));
178+
expect(envelope(e)).toEqual({ code: 'VALIDATION_ERROR', status: 400 });
179+
expect(e.message).toBe(
180+
`Object extension '${LEGACY_SHORT}' extends '${LEGACY_SHORT}', but that object is registered as '${LEGACY_KEY}': `
181+
+ `an extension is filed under the name it is written with, so '${LEGACY_SHORT}' would open a second object `
182+
+ `beside '${LEGACY_KEY}'. Save it as '${LEGACY_KEY}', with \`extend\` set to '${LEGACY_KEY}'.`,
183+
);
184+
expect(e.issues?.map((i: any) => i.path)).toEqual(['extend']);
185+
}
186+
expect(await rows()).toEqual([]);
187+
expect(engine.registry.getObjectContributors(LEGACY_SHORT)).toEqual([]);
188+
expect(engine.registry.getObjectContributors(LEGACY_KEY).map((c: any) => c.packageId)).toEqual([BASE]);
189+
});
190+
191+
it('control: the key the object is registered under is admitted and extends that object', async () => {
192+
const { engine, p, rows } = await boot();
193+
await p.saveMetaItem({ type: 'object_extension', name: LEGACY_KEY, packageId: PAIR, item: region(LEGACY_KEY), mode: 'draft' });
194+
await p.publishMetaItem({ type: 'object_extension', name: LEGACY_KEY, packageId: PAIR });
195+
expect(await rows()).toEqual([{ type: 'object_extension', name: LEGACY_KEY, state: 'active' }]);
196+
expect(engine.registry.getObjectContributors(LEGACY_KEY).map((c: any) => [c.packageId, c.ownership]))
197+
.toEqual([[BASE, 'own'], [PAIR, 'extend']]);
198+
expect(Object.keys((engine.registry.getObject(LEGACY_KEY) as any)?.fields ?? {})).toEqual(expect.arrayContaining(['region__c', 'subject']));
199+
expect(engine.registry.getObjectContributors(LEGACY_SHORT)).toEqual([]);
200+
});
201+
});

0 commit comments

Comments
 (0)