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
17 changes: 17 additions & 0 deletions .changeset/20864-precedence-loader-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/service-automation': minor
---

fix(automation): boot-time flow precedence takes which same-named flow is the packaged one from the package loader's set, not from the flow definitions' own provenance (#20864)

Clause-②: yes (widening)

When several flow definitions share one name at startup, the automation plugin arms one of them and shadows the rest: a flow authored in the deployment wins over the packaged flow of that name (ADR-0005). Which contender counts as the packaged one is now the answer of the set of flows a managed package's loader registered. That is the same answer the ADR-0126 §7.3 subflow guards, the arming gate and the activation switch read since #20761. The package provenance a flow definition carries is kept for display only.

- `resolveFlowPrecedence(items, logger?, packagedFlowOwner?)` and `describeFlowContender(item, packagedFlowOwner?)` take the reader as a new optional last argument, typed `PackagedFlowSource` (the reader `AutomationEngine.setPackagedFlowSource` takes). `AutomationServicePlugin` passes the engine's own `packagedFlowOwner` for you.
- A definition that claims a package's provenance for a name no package loaded ranks as a flow of the deployment. The shadowing record (`getShadowedFlows()`, and the startup warnings) no longer names that package as its source.
- With no reader, no contender is packaged. That is the engine's own answer when no reader is attached.
- Two contenders that both rank as the deployment's keep the order they were listed in. The package id orders packaged contenders only, as before.
- A startup whose registry the package loader and the stored-flow hydration filled arms the same flows as before: those entries already agree with the loader's set.

**If you call `resolveFlowPrecedence` or `describeFlowContender` yourself:** pass the loader's-set reader as the last argument, for example `(name) => engine.packagedFlowOwner(name)`. Without it no contender ranks as packaged.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ function flowBody(name: string, marker: string) {
const packagedBody = () => ({ ...flowBody(FLOW, 'PACKAGED'), _packageId: 'crm' });
const runtimeBody = () => ({ ...flowBody(FLOW, 'RUNTIME') });

/**
* [#20864] The loader's set, read the way the metadata protocol's
* `packagedArtifactOwner` reads it: the owner of the registry's ARTIFACT view
* of `name` (`SchemaRegistry.getArtifactItem`), never a listed body's stamps.
* Precedence classifies by this set (see `flow-precedence-loader-set.test.ts`),
* so a suite about the ORDER it imposes hands it one, as the boot pull does.
*/
function loaderSetOf(registry: any) {
return (name: string): string | undefined => {
const owner = (registry.getArtifactItem('flow', name) as { _packageId?: unknown } | undefined)?._packageId;
return typeof owner === 'string' && owner !== '' ? owner : undefined;
};
}

/** An explicit loader's set, for the list-only cases below. */
const setOf = (set: Record<string, string>) => (name: string): string | undefined => set[name];

/** A registry holding both contenders, registered in the given order. */
function registryWithBoth(order: 'package-first' | 'runtime-first') {
const registry: any = new SchemaRegistry();
Expand All @@ -62,12 +79,15 @@ function registryWithBoth(order: 'package-first' | 'runtime-first') {

/**
* The boot pull, reduced to the two steps under test: resolve precedence, then
* register the winners. Mirrors `plugin.ts`'s flow pull — see the comment there.
* register the winners. Mirrors `plugin.ts`'s flow pull — see the comment there
* — including the loader's set, attached to the engine and read back through
* its `packagedFlowOwner`.
*/
function bootPull(registry: any, logger: { warn(m: string, meta?: unknown): void } = silentLogger) {
const engine = new AutomationEngine(silentLogger);
engine.setPackagedFlowSource(loaderSetOf(registry));
const listed = registry.listItems('flow') as unknown[];
const resolved = resolveFlowPrecedence(listed, logger);
const resolved = resolveFlowPrecedence(listed, logger, (name) => engine.packagedFlowOwner(name));
for (const entry of resolved) {
engine.registerFlow(entry.name, entry.definition as never);
if (entry.shadowing) engine.recordFlowShadowing(entry.shadowing);
Expand Down Expand Up @@ -182,8 +202,11 @@ describe('#11997 — precedence is a total order, not an iteration order', () =>
const a = { ...flowBody(FLOW, 'FROM_ALPHA'), _packageId: 'alpha' };
const b = { ...flowBody(FLOW, 'FROM_BETA'), _packageId: 'beta' };

const forward = resolveFlowPrecedence([a, b], silentLogger);
const backward = resolveFlowPrecedence([b, a], silentLogger);
// The loader's set names ONE owner per name; the second package's
// entry is told apart by the registry's per-entry artifact test.
const loaderSet = setOf({ [FLOW]: 'alpha' });
const forward = resolveFlowPrecedence([a, b], silentLogger, loaderSet);
const backward = resolveFlowPrecedence([b, a], silentLogger, loaderSet);

expect((forward[0].definition as any).label).toBe('FROM_ALPHA');
expect((backward[0].definition as any).label).toBe('FROM_ALPHA');
Expand All @@ -201,19 +224,22 @@ describe('#11997 — precedence is a total order, not an iteration order', () =>
});

it('classifies the sys_metadata rehydration sentinel as runtime, not as a package', () => {
// Judged with the loader's set HOLDING the name, so what is pinned is
// the per-entry test inside a held name, not the set's own answer.
const loaderSet = setOf({ [FLOW]: 'crm' });
// `loadMetaFromDb` rehydrates overlay rows with a synthetic
// `_packageId = 'sys_metadata'` (ADR-0005 §Provenance edge case). A
// bare `_packageId` truthiness test would misread it as packaged.
expect(describeFlowContender({ name: FLOW, _packageId: 'sys_metadata' })).toEqual({
expect(describeFlowContender({ name: FLOW, _packageId: 'sys_metadata' }, loaderSet)).toEqual({
source: 'runtime',
packageId: 'sys_metadata',
});
// And a tenant-authored overlay bound to a REAL package id is runtime
// too — provenance is the axis, not the id (cloud#970).
expect(
describeFlowContender({ name: FLOW, _packageId: 'app.built_by_studio', _provenance: 'org' }),
describeFlowContender({ name: FLOW, _packageId: 'app.built_by_studio', _provenance: 'org' }, loaderSet),
).toEqual({ source: 'runtime', packageId: 'app.built_by_studio' });
expect(describeFlowContender({ name: FLOW, _packageId: 'crm' })).toEqual({
expect(describeFlowContender({ name: FLOW, _packageId: 'crm' }, loaderSet)).toEqual({
source: 'package',
packageId: 'crm',
});
Expand All @@ -222,7 +248,7 @@ describe('#11997 — precedence is a total order, not an iteration order', () =>
it('a tenant overlay beats the package even when both carry a real package id', () => {
const packaged = { ...flowBody(FLOW, 'PACKAGED'), _packageId: 'crm' };
const tenant = { ...flowBody(FLOW, 'TENANT'), _packageId: 'crm', _provenance: 'org' };
const resolved = resolveFlowPrecedence([packaged, tenant], silentLogger);
const resolved = resolveFlowPrecedence([packaged, tenant], silentLogger, setOf({ [FLOW]: 'crm' }));
expect((resolved[0].definition as any).label).toBe('TENANT');
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#20864, ADR-0126 §2 / §7.3] Boot-time flow precedence classifies its
// contenders by THE LOADER'S SET — the reader the engine holds
// (`setPackagedFlowSource` / `packagedFlowOwner`) — and never by the stamps a
// flow body carries.
//
// For a flow, "packaged" means exactly "loaded by the loader from a managed
// package", and every classification the engine makes reads that one
// server-held fact. The §7.3 guards, the arming gate and the activation door
// were moved onto it first (`packaged-flow-source.test.ts`); this file pins the
// last reader, the precedence that decides which same-named body the boot pull
// arms. Every pin hands precedence an EXPLICIT set and reads the answer off the
// classification, the shadowing record and the armed body — and the last block
// boots the real plugin, so the wiring from the metadata protocol's answer to
// the boot pull is pinned too, not only the pure function.

import { describe, it, expect, vi } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import type { Plugin, PluginContext } from '@objectstack/core';
import { AutomationServicePlugin } from './plugin.js';
import type { AutomationEngine, FlowContender } from './engine.js';
import { resolveFlowPrecedence, describeFlowContender, renderFlowContender } from './flow-precedence.js';

const FLOW = 'order_sync';

function flowBody(name: string, label: string, extra: Record<string, unknown> = {}) {
return {
name,
label,
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start', config: {} },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
...extra,
};
}

/** The stamps a body carries when it claims a package's provenance. */
const CLAIMED = { _packageId: 'crm', _provenance: 'package' };
/** What the boot hydration leaves on a stored row: the tenant marker, here with a binding. */
const TENANT = { _packageId: 'app.ops', _provenance: 'org' };

/** The loader's entry for FLOW, as the registry holds it once the loader stamped it. */
const loaderEntry = () => flowBody(FLOW, 'LOADER', CLAIMED);
/** A stored tenant row of the same name, as the hydration registers it. */
const tenantRow = () => flowBody(FLOW, 'TENANT', TENANT);

/** An explicit loader's set: flow name → the package that loaded it. */
const setOf = (set: Record<string, string>) => (name: string): string | undefined => set[name];
const HOLDS_FLOW = setOf({ [FLOW]: 'crm' });
const HOLDS_NOTHING = setOf({});

const silent = () => ({ warn: vi.fn() });

describe('[#20864] boot-time precedence classifies by the loader\'s set, not by body stamps', () => {
it('a contender claiming a package ranks as tenant-authored when the set does not hold its name', () => {
const claimed = loaderEntry();
expect(describeFlowContender(claimed, HOLDS_NOTHING)).toEqual({ source: 'runtime', packageId: 'crm' });
// The set is keyed by NAME: holding another flow vouches for nothing here.
expect(describeFlowContender(claimed, setOf({ other_flow: 'crm' }))).toEqual({
source: 'runtime',
packageId: 'crm',
});
// The operator reads it as the tenant row it is; the claimed package
// never reaches the sentence.
expect(renderFlowContender(describeFlowContender(claimed, HOLDS_NOTHING))).toBe(
renderFlowContender({ source: 'runtime' }),
);
});

it('the same contender ranks as packaged when the set holds its name', () => {
expect(describeFlowContender(loaderEntry(), HOLDS_FLOW)).toEqual({ source: 'package', packageId: 'crm' });
});

it('a stored tenant row stays tenant-authored inside a name the set holds', () => {
expect(describeFlowContender(tenantRow(), HOLDS_FLOW)).toEqual({ source: 'runtime', packageId: 'app.ops' });
});

it('the same two bodies rank by the set: only a held name puts a packaged contender in the shadowing record', () => {
const held = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_FLOW);
expect(held).toHaveLength(1);
expect((held[0].definition as { label: string }).label).toBe('TENANT');
expect(held[0].shadowing).toEqual({
name: FLOW,
armed: { source: 'runtime', packageId: 'app.ops' },
shadowed: [{ source: 'package', packageId: 'crm' }],
});

const unheld = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING);
expect(unheld[0].shadowing).toEqual({
name: FLOW,
armed: { source: 'runtime', packageId: 'app.ops' },
shadowed: [{ source: 'runtime', packageId: 'crm' }],
});
});

it('the shadowing record names the armed body in either arrival order, and the pull warning agrees', () => {
for (const listed of [
[loaderEntry(), tenantRow()],
[tenantRow(), loaderEntry()],
]) {
const logger = silent();
const [winner] = resolveFlowPrecedence(listed, logger, HOLDS_FLOW);
expect((winner.definition as { label: string }).label).toBe('TENANT');
expect(winner.shadowing?.armed).toEqual({ source: 'runtime', packageId: 'app.ops' });
expect(winner.shadowing?.shadowed).toEqual([{ source: 'package', packageId: 'crm' }]);

expect(logger.warn).toHaveBeenCalledTimes(1);
const [message, meta] = logger.warn.mock.calls[0] as [string, { armed: FlowContender; shadowed: FlowContender[] }];
expect(message).toContain(`arming ${renderFlowContender({ source: 'runtime' })}`);
expect(message).toContain(renderFlowContender({ source: 'package', packageId: 'crm' }));
expect(meta.armed).toEqual(winner.shadowing?.armed);
expect(meta.shadowed).toEqual(winner.shadowing?.shadowed);
}
});

it('tenant-ranked contenders keep arrival order: a body\'s own id does not order them', () => {
// Both tenant-ranked (the set holds nothing). By id alone the tenant
// row would sort first; arrival order decides instead, so a claimed id
// cannot buy the armed slot.
const first = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_NOTHING);
expect((first[0].definition as { label: string }).label).toBe('LOADER');
const second = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING);
expect((second[0].definition as { label: string }).label).toBe('TENANT');
});

it('with no reader nothing is packaged: the engine\'s fail-closed answer', () => {
expect(describeFlowContender(loaderEntry())).toEqual({ source: 'runtime', packageId: 'crm' });

const [winner] = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent());
const record = winner.shadowing!;
expect([record.armed, ...record.shadowed].map((c) => c.source)).toEqual(['runtime', 'runtime']);

// A reader answering an empty owner names no package either — the
// normalization `AutomationEngine.packagedFlowOwner` applies.
expect(describeFlowContender(loaderEntry(), () => '')).toEqual({ source: 'runtime', packageId: 'crm' });
});

it('asks the set once per contested name, and never for an uncontested one', () => {
const reader = vi.fn(HOLDS_FLOW);
resolveFlowPrecedence([flowBody('lonely_flow', 'L', CLAIMED), loaderEntry(), tenantRow()], silent(), reader);
expect(reader.mock.calls).toEqual([[FLOW]]);
});
});

// ── The boot pull, through the real plugin ─────────────────────────────────

/** The one seam the boot pull reads — `registry.listItems('flow')`. */
function fakeObjectqlPlugin(flows: unknown[]): Plugin {
return {
name: 'fake-objectql',
version: '1.0.0',
async init(ctx: PluginContext) {
(ctx as unknown as { registerService(n: string, s: unknown): void }).registerService('objectql', {
registry: {
listItems: (type: string) => (type === 'flow' ? flows : []),
getObject: () => undefined,
},
});
},
};
}

/**
* The metadata protocol, reduced to the loader's-set read the plugin's
* `packagedFlowReader` asks, plus an empty execution view so the kernel:ready
* sync registers nothing over the pull under test.
*/
function fakeProtocolPlugin(packagedArtifactOwner: (request: { type: string; name: string }) => string | undefined): Plugin {
return {
name: 'fake-protocol',
version: '1.0.0',
async init(ctx: PluginContext) {
(ctx as unknown as { registerService(n: string, s: unknown): void }).registerService('protocol', {
packagedArtifactOwner,
async getMetaItemsForExecution() {
return { items: [] };
},
});
},
};
}

async function boot(flows: unknown[], protocol?: Plugin) {
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
kernel.use(fakeObjectqlPlugin(flows));
if (protocol) kernel.use(protocol);
kernel.use(new AutomationServicePlugin());
await kernel.bootstrap();
return { kernel, engine: kernel.getService<AutomationEngine>('automation') };
}

describe('[#20864] the boot pull hands precedence the engine\'s loader\'s-set reader', () => {
it('a held name: the shadowing receipt names the tenant row armed and the loader entry packaged', async () => {
const owner = vi.fn((request: { type: string; name: string }) =>
request.type === 'flow' && request.name === FLOW ? 'crm' : undefined,
);
const { kernel, engine } = await boot([loaderEntry(), tenantRow()], fakeProtocolPlugin(owner));
try {
expect(engine.getShadowedFlows()).toEqual([
{
name: FLOW,
armed: { source: 'runtime', packageId: 'app.ops' },
shadowed: [{ source: 'package', packageId: 'crm' }],
},
]);
expect(((await engine.getFlow(FLOW)) as { label?: string } | null)?.label).toBe('TENANT');
// One source: the pull asked the protocol for this flow, and the
// engine's own reader gives the same answer.
expect(owner).toHaveBeenCalledWith({ type: 'flow', name: FLOW });
expect(engine.packagedFlowOwner(FLOW)).toBe('crm');
} finally {
await kernel.shutdown();
}
});

it('an unheld name: no contender is packaged, whatever the bodies claim', async () => {
const { kernel, engine } = await boot([tenantRow(), loaderEntry()], fakeProtocolPlugin(() => undefined));
try {
const [record] = engine.getShadowedFlows();
expect(record.name).toBe(FLOW);
expect([record.armed, ...record.shadowed].map((c) => c.source)).toEqual(['runtime', 'runtime']);
} finally {
await kernel.shutdown();
}
});

it('no protocol service: fail closed, no contender is packaged', async () => {
const { kernel, engine } = await boot([loaderEntry(), tenantRow()]);
try {
const [record] = engine.getShadowedFlows();
expect([record.armed, ...record.shadowed].map((c) => c.source)).toEqual(['runtime', 'runtime']);
} finally {
await kernel.shutdown();
}
});
});
Loading
Loading