Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/20679-automation-door-package-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/runtime': patch
'@objectstack/metadata-protocol': minor
---

fix(runtime): the `/automation` write doors refuse a packaged flow, as the metadata door does (#20679)

Clause-②: yes (widening)

A flow that a code package ships has a locked base (ADR-0126 §2): changing or removing it in place is refused. `PUT /api/v1/meta/flow/:name` already refused it. The two `/automation` definition doors did not: an administrator holding `manage_metadata` could rewrite a packaged flow in the live engine with `PUT /api/v1/automation/:name` or with `POST /api/v1/automation` under its name (a create onto an existing name overwrites it), or remove it with `DELETE /api/v1/automation/:name`.

All three now answer a packaged flow with the same code and status the metadata door gives (`403` `NOT_OVERRIDABLE`), and with the same sentence wherever the metadata protocol's own package door answers. The refusal comes before the engine is called, so nothing is registered or removed. On `DELETE`, it also comes before the engine's own `DELETE_RESTRICTED` / `409` for a packaged subflow that packaged callers still reach.

What is not refused:

- A flow that no code package ships, including a flow created with `POST /api/v1/automation` or authored through the metadata door. It is updated and removed as before.
- `POST /api/v1/automation/:name/clone`, which copies a packaged flow under a new name. This is the supported way to customize one (ADR-0126 §7.1).
- `POST /api/v1/automation/:name/toggle`, the switch that turns a packaged flow on or off (ADR-0126 §7.2).
- A deployment that sets `OS_METADATA_WRITABLE=flow`. It opens both doors, as the refusal message says.

**The widening.** `@objectstack/metadata-protocol` gains one public method, `ObjectStackProtocolImplementation.packagedBaseRefusal({ type, name, operation })`. It returns the refusal the metadata door would give for writing (`'save'`) or removing (`'delete'`) an existing item because a code package ships it, or `null` when that door would not refuse on this ground. `saveMetaItem` and `deleteMetaItem` call the same code, so the two doors cannot disagree. Their own refusals are unchanged.
121 changes: 121 additions & 0 deletions packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20679, ADR-0126 §2] `packagedBaseRefusal` — the `/meta` door's
* locked-base verdict, as a value, for a second door onto the same artifact.
*
* It is not a new rule. It hands out the SAME verdict `saveMetaItem` and
* `deleteMetaItem` reach — the package doors both now call, lifted out of them
* unchanged, `refusePackagedBaseOverride` / `refusePackagedBaseRemoval` — so this file
* pins two things and only two:
*
* 1. it answers exactly what those two methods throw for the same item (the
* ONE-emitter claim: same code, status and sentence);
* 2. its matrix is the metadata door's matrix, including the answers that are
* deliberately `null` — a name no package ships, a Regime O overlay type,
* the #6960 delete carve-out, and the operator hatch.
*
* The registry double serves only `getArtifactItem`, which is all the verdict
* reads; what it returns is what the real `SchemaRegistry` returns for an
* artifact a code package registered (`_packageId` stamped, package
* provenance). `@objectstack/objectql` cannot be imported here: it depends on
* this package.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';
import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';

const PACKAGE_ID = 'com.example.pkg';

const shipped = (name: string, extra: Record<string, unknown> = {}) =>
({ name, label: name, _packageId: PACKAGE_ID, _provenance: 'package', ...extra });

/** Packaged artifacts of three regimes: behavioral (`flow`), overlay (`view`), rolled-back overlay (`page`). */
const ARTIFACTS = new Map<string, Map<string, unknown>>([
['flow', new Map([['pkg_flow', shipped('pkg_flow', { type: 'autolaunched', nodes: [], edges: [] })]])],
['view', new Map([['pkg_view', shipped('pkg_view')]])],
['page', new Map([['pkg_page', shipped('pkg_page')]])],
]);

function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation {
const registry = { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) };
return new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), environmentId);
}

const shape = (e: any) => (e ? { code: e.code, status: e.status } : null);

afterEach(() => {
delete process.env.OS_METADATA_WRITABLE;
ObjectStackProtocolImplementation.resetEnvWritableCache();
resetEnvWritableMetadataTypes();
});

describe('packagedBaseRefusal — the /meta door\'s locked-base verdict, handed to a second door', () => {
for (const environmentId of [undefined, 'env_1']) {
it(`refuses a packaged flow's save AND removal on ${environmentId ? 'an environment' : 'a host-config'} kernel`, () => {
const p = protocolOn(environmentId);
expect(shape(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' })))
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
expect(shape(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' })))
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
});
}

it('ONE emitter: the value equals what saveMetaItem / deleteMetaItem throw for the same item', async () => {
const p = protocolOn('env_1');
const thrownBy = (run: Promise<unknown>) => run.then(() => undefined, (e: any) => e);

const save = await thrownBy(p.saveMetaItem({ type: 'flow', name: 'pkg_flow', item: { name: 'pkg_flow', label: 'x' } }));
const saveVerdict: any = p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' });
expect({ ...shape(save), message: save?.message }).toEqual({ ...shape(saveVerdict), message: saveVerdict?.message });

const del = await thrownBy(p.deleteMetaItem({ type: 'flow', name: 'pkg_flow' }));
const delVerdict: any = p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' });
expect({ ...shape(del), message: del?.message }).toEqual({ ...shape(delVerdict), message: delVerdict?.message });
});

it('folds the type at the producer — a plural spelling cannot address around the lock', () => {
const p = protocolOn(undefined);
expect(shape(p.packagedBaseRefusal({ type: 'flows', name: 'pkg_flow', operation: 'save' })))
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
});

it('a name no code package ships is not locked — creation is not this verdict\'s question', () => {
const p = protocolOn(undefined);
expect(p.packagedBaseRefusal({ type: 'flow', name: 'customer_flow', operation: 'save' })).toBeNull();
expect(p.packagedBaseRefusal({ type: 'flow', name: 'customer_flow', operation: 'delete' })).toBeNull();
});

it('a Regime O overlay type (allowOrgOverride) is never refused on this ground', () => {
const p = protocolOn(undefined);
expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'save' })).toBeNull();
expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'delete' })).toBeNull();
});

it('mirrors the #6960 carve-out: a rolled-back overlay type refuses the write but not the removal', () => {
const p = protocolOn(undefined);
expect(shape(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' })))
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
expect(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'delete' })).toBeNull();
});

it('a lookup that FAILS is re-raised, never handed out as a verdict', () => {
// The lifted helpers throw, as the inline code did; only the refusal is
// turned into a value. A registry that cannot answer must not become a
// well-formed "refused" (or "allowed") — it stays the fault it is.
const registry = { getArtifactItem: () => { throw new Error('registry unreadable'); } };
const p = new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), undefined);
expect(() => p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }))
.toThrow('registry unreadable');
expect(() => p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }))
.toThrow('registry unreadable');
});

it('reads the operator hatch through the same predicate the metadata door reads', () => {
process.env.OS_METADATA_WRITABLE = 'flow';
ObjectStackProtocolImplementation.resetEnvWritableCache();
const p = protocolOn(undefined);
expect(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' })).toBeNull();
expect(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' })).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ async function expectSpellingRefusal(run: () => Promise<unknown>) {
}

describe('#9157 — the population, re-derived from the code rather than from the card', () => {
it('every `/meta` request-boundary verb with a required `type` calls the fold — all thirteen', () => {
it('every `/meta` request-boundary verb with a required `type` calls the fold — all fourteen', () => {
// ⭐ The card hand-listed "nine fold, three do not". Hand-listed sets of
// this shape have shipped short before, so the set is DERIVED here and
// the derivation is the pin: a tenth verb arriving unfolded turns this
Expand Down Expand Up @@ -280,6 +280,9 @@ describe('#9157 — the population, re-derived from the code rather than from th
// in-process caller hands it a type spelling too.
'getMetaItemsForExecution',
'historyMetaItem',
// [#20679] The locked-base verdict a second write door asks — it
// takes the type a caller names, so it folds at the producer too.
'packagedBaseRefusal',
'publishMetaItem',
'rollbackMetaItem',
'saveMetaItem',
Expand Down
Loading
Loading