Skip to content

Commit 44d5553

Browse files
committed
fix(metadata-protocol): admit the pair-row removal for flow only
The contract review on #28693 found isPairRowRemoval keyed on isOpenOverrideType, so a /meta delete naming the pair also slipped past the sealed-removal refusal for every other open type: a mapping pair-row delete answered success instead of 403 NOT_OVERRIDABLE, and a view pair-row delete reached the store as runtime-only instead of main's override-artifact intent. The removal is now admitted only for a type whose ADR-0146 D9 conditions are delivered (CONDITIONS_DELIVERED, flow today). Two real-engine pins hold main's verdict for the other open types: the mapping delete still refuses on the host-config and env kernels and keeps the row, and the view delete still tells the store override-artifact. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0e67bba commit 44d5553

3 files changed

Lines changed: 73 additions & 12 deletions

File tree

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -246,17 +246,21 @@ function isOpenOverrideType(singular: string): boolean {
246246

247247
/**
248248
* [#28412, ADR-0146 D9 item 1] Whether a removal addressed to `packageId`
249-
* removes the shipping package's PAIR row of an open-override type — the
250-
* row the pair's declared override is stored in. Such a removal is admitted
251-
* past the sealed-removal refusal: it removes no managed definition, and the
252-
* code package's item is what every read serves (and, for a flow, what the
253-
* automation engine arms) once the row is gone — the narrowing direction the
254-
* #6960 ruling names. ⛔ Only the pair, named: a removal naming no package, or
255-
* any other package, keeps the sealed refusal it had.
249+
* removes the shipping package's PAIR row of a type whose D9 conditions are
250+
* delivered ({@link CONDITIONS_DELIVERED}: `flow` today) — the row the pair's
251+
* declared override is stored in. Such a removal is admitted past the
252+
* sealed-removal refusal: it removes no managed definition, and the code
253+
* package's flow is what every read serves and what the automation engine
254+
* arms once the row is gone — the narrowing direction the #6960 ruling names.
255+
* ⛔ Only the pair, named: a removal naming no package, or any other package,
256+
* keeps the sealed refusal it had. ⛔ Only a delivered type: every other open
257+
* type (`view`, `mapping`, …) keeps main's delete verdict — its own removal
258+
* carve-out or its sealed refusal — untouched by this card.
256259
*/
257260
export function isPairRowRemoval(type: string, basePackageId: unknown, packageId: unknown): boolean {
258261
const singular = PLURAL_TO_SINGULAR[type] ?? type;
259-
if (!isOpenOverrideType(singular) || typeof basePackageId !== 'string') return false;
262+
if (!CONDITIONS_DELIVERED.has(singular) || !isOpenOverrideType(singular)) return false;
263+
if (typeof basePackageId !== 'string') return false;
260264
if (typeof packageId !== 'string' || packageId.length === 0) return false;
261265
const pair = customizationPackageId(basePackageId);
262266
return pair.ok && pair.value === packageId;

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28290,7 +28290,8 @@ export class ObjectStackProtocolImplementation implements
2829028290
// here, but a stored row under its name is, as repair.
2829128291
const originGatedRefusal = this.originGatedRemovalRefusal(request.type, request.name);
2829228292
// [#28412, ADR-0146 D9 item 1] A removal naming the shipping package's
28293-
// pair, of a type whose declared override is open (`flow`): it removes
28293+
// pair, of a type whose D9 conditions are delivered (`flow` only —
28294+
// every other open type keeps main's verdict): it removes
2829428295
// the row the override is stored in, and the code package's item is
2829528296
// served — and armed — again. Admitted past the sealed-removal refusal
2829628297
// and told to the store as a runtime-only removal, as the declared

‎packages/objectql/src/protocol-flow-custom-override.test.ts‎

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@
2121
* `@objectstack/metadata-protocol`'s `dist/`, so a change there is read here
2222
* after that package is rebuilt.
2323
*/
24-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
24+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2525
import type { ServiceObject } from '@objectstack/spec/data';
2626
import {
2727
ObjectStackProtocolImplementation,
2828
isServedOverrideRowOf,
2929
resetEnvWritableMetadataTypes,
3030
storedRowBindingOf,
31+
SysMetadataRepository,
3132
} from '@objectstack/metadata-protocol';
3233
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';
3334
import { ObjectQL } from './engine.js';
@@ -37,6 +38,8 @@ const PAIR = 'com.example.showcase.custom';
3738
const DEPENDENT = 'com.default.acme-custom';
3839
const FLOW = 'showcase_task_escalate';
3940
const DECLARATION = { package: BASE, basedOn: '1.0.0' } as const;
41+
const MAPPING = 'showcase_import_map';
42+
const VIEW = 'showcase_task.default';
4043

4144
const sysMetadataObject: ServiceObject = {
4245
name: 'sys_metadata',
@@ -159,10 +162,15 @@ function baseManifest(): Record<string, unknown> {
159162
fields: { title: { name: 'title', label: 'title', type: 'text' } },
160163
}],
161164
flows: [flowBody('SHIPPED')],
165+
mappings: [{ name: MAPPING, label: 'Import', sourceFormat: 'csv', targetObject: 'showcase_task', fieldMapping: [] }],
166+
views: [{
167+
object: 'showcase_task',
168+
list: { label: 'All Tasks', type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: [{ field: 'title' }] },
169+
}],
162170
};
163171
}
164172

165-
async function boot() {
173+
async function boot(environmentId?: string) {
166174
const engine = new ObjectQL();
167175
const { driver, stores } = makeMemoryDriver();
168176
engine.registerDriver(driver, true);
@@ -172,7 +180,7 @@ async function boot() {
172180
engine.registry.registerObject(o as any, 'test-package');
173181
}
174182
engine.registerApp(baseManifest());
175-
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map());
183+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId);
176184
const rows = () => Array.from(stores.get('sys_metadata')?.values() ?? [])
177185
.filter((r) => r.type === 'flow' && r.name === FLOW);
178186
return { engine, protocol, rows };
@@ -286,3 +294,51 @@ describe('[#28412] a declared .custom flow override is the packaged flow, at eve
286294
expect(await listed(protocol, 'execution')).toEqual([[BASE, 'SHIPPED']]);
287295
});
288296
});
297+
298+
/**
299+
* [#28412, contract review 6559358939] The pair-row removal is admitted for
300+
* `flow` alone — the one type whose ADR-0146 D9 conditions this change
301+
* delivers. Every other open override type keeps the removal verdict and the
302+
* receipt intent it has on main: a `mapping` (no overlay channel, outside the
303+
* #6960 carve-out) is still refused, and a `view` (overlay-capable) is still
304+
* removed as an `override-artifact` delete.
305+
*/
306+
describe('[#28412] the pair-row removal is flow-only: other open types keep main\'s verdict', () => {
307+
const storePairRow = (engine: ObjectQL, type: string, name: string, body: Record<string, unknown>) =>
308+
engine.insert('sys_metadata', {
309+
type, name, organization_id: null, package_id: PAIR,
310+
metadata: JSON.stringify({ ...body, overrides: { ...DECLARATION } }), state: 'active', version: 1,
311+
}, { context: { isSystem: true } });
312+
const rowsOf = async (engine: ObjectQL, type: string, name: string) =>
313+
((await engine.find('sys_metadata', { where: { type, name }, context: { isSystem: true } })) as any[]);
314+
315+
for (const environmentId of [undefined, 'env_1']) {
316+
const kernel = environmentId ? 'environment' : 'host-config';
317+
it(`a mapping's pair-row delete naming the pair still answers 403 NOT_OVERRIDABLE, and the row stays (${kernel} kernel)`, async () => {
318+
const { protocol, engine } = await boot(environmentId);
319+
await install(protocol, PAIR);
320+
await storePairRow(engine, 'mapping', MAPPING, { name: MAPPING, label: 'Ours', sourceFormat: 'csv', targetObject: 'showcase_task', fieldMapping: [] });
321+
await expect(protocol.deleteMetaItem({ type: 'mapping', name: MAPPING, packageId: PAIR } as any))
322+
.rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
323+
expect((await rowsOf(engine, 'mapping', MAPPING)).map((r) => r.package_id)).toEqual([PAIR]);
324+
});
325+
}
326+
327+
it('a view\'s pair-row delete naming the pair is removed as an override-artifact delete, as on main', async () => {
328+
const { protocol, engine } = await boot();
329+
await install(protocol, PAIR);
330+
await storePairRow(engine, 'view', VIEW, {
331+
name: VIEW, object: 'showcase_task', viewKind: 'list', label: 'Ours',
332+
config: { type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: [{ field: 'title' }] },
333+
});
334+
const removal = vi.spyOn(SysMetadataRepository.prototype, 'delete');
335+
try {
336+
await protocol.deleteMetaItem({ type: 'view', name: VIEW, packageId: PAIR } as any);
337+
expect(removal).toHaveBeenCalledTimes(1);
338+
expect(removal.mock.calls[0][1]).toMatchObject({ intent: 'override-artifact', packageId: PAIR });
339+
} finally {
340+
removal.mockRestore();
341+
}
342+
expect((await rowsOf(engine, 'view', VIEW)).filter((r) => r.state === 'active')).toEqual([]);
343+
});
344+
});

0 commit comments

Comments
 (0)