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
34 changes: 34 additions & 0 deletions .changeset/wild-pears-remain.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
---
"@objectstack/service-automation": patch
"@objectstack/objectql": patch
---

Arm a deterministic flow when a runtime-authored flow reuses a packaged flow's name

A runtime-authored flow that reused a packaged flow's name silently replaced it,
and which of the two ended up armed depended on registration order. The metadata
registry keys items `packageId:name` and deliberately coexists both (ADR-0048
§3.4), `listItems('flow')` returns both with no precedence, and the automation
engine keys flows by bare name — so the boot pull registered both under one key
and Map iteration order picked the survivor. Measured: registering the package
first armed the runtime flow, registering the runtime row first armed the
packaged flow, with no warning and no way to tell which had won.

The boot pull now collapses same-named definitions before anything is armed,
applying the ADR-0005 overlay precedence ADR-0048 §3.4 routes this case to: the
runtime/DB overlay wins over the packaged artifact, which is the sanctioned
override path. Two packages shipping one bare name resolve by package id, so
boot order no longer decides anything.

Collisions are no longer silent. The pull warns once per colliding name — naming
the name, every contender, and which one is armed — and repeats it at bootstrap
beside the other automation audits. `getShadowedFlows()` is a new receipt listing
each contested name with its armed and shadowed definitions, and
`getFlowRuntimeStates()` rows now carry `armedFrom`/`shadowed` for contested
names; previously the displaced definition was invisible by construction, since
the flow map holds one entry per name. The `Pulled N flow(s)` line now counts
distinct names rather than registrations.

`isCodeArtifactBody` is exported from `@objectstack/objectql` so consumers that
collapse same-named metadata answer "does a code package ship this?" with the
registry's own test instead of re-deriving it from `_packageId`.
9 changes: 9 additions & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,15 @@ export {
DEFAULT_EXTENDER_PRIORITY,
} from './registry.js';
export type { ObjectContributor, SchemaRegistryOptions } from './registry.js';
// [#11997] The canonical "does a code package ship this body?" test (ADR-0029
// D9.6). Exported because the ADR-0005 overlay precedence is not the registry's
// alone to apply: any consumer that collapses two same-named contenders into one
// slot — the automation engine's flow map is the first — has to answer the SAME
// question, and its whole reason for existing is that callers must not drift
// into a second answer. Consumers ask this; they do not re-derive it from
// `_packageId`, which cannot tell a tenant overlay from a code artifact on its
// own (see the function's own doc, and `isTenantAuthored` above it).
export { isCodeArtifactBody } from './registry.js';
// [#7865] Injected-column provenance — the machine-readable marker for anchors
// the registry registers without provisioning storage (external objects,
// ADR-0015). Canonical home: `@objectstack/metadata-core`, beside the
Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1132,7 +1132,7 @@ function isTenantAuthored(item: unknown): boolean {
* Truthy `_packageId`, not the `'sys_metadata'` rehydration sentinel, and not
* tenant provenance.
*/
function isCodeArtifactBody(item: unknown): boolean {
export function isCodeArtifactBody(item: unknown): boolean {
const it = item as { _packageId?: unknown } | null | undefined;
if (!it || !it._packageId || it._packageId === 'sys_metadata') return false;
return !isTenantAuthored(it);
Expand Down
80 changes: 80 additions & 0 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1240,6 +1240,34 @@ function graftConditionEnvelopes(converted: unknown, parsed: unknown): unknown {
return converted;
}

/**
* [#11997] Where one flow body came from, as the boot pull could tell.
*
* `'package'` — a code-shipped artifact (`isCodeArtifactBody`, ADR-0029 D9.6).
* `'runtime'` — a `sys_metadata`/runtime-authored row with no real package
* provenance: the ADR-0005 overlay, and the sanctioned override path per
* ADR-0048 §1.5.
*/
export interface FlowContender {
source: 'package' | 'runtime';
/** The owning package id, when a code package ships this body. */
packageId?: string;
}

/**
* [#11997] What the ADR-0005 overlay precedence decided for one bare flow name.
*
* Emitted only when a name had more than one contender at pull time. `armed` is
* the body that is actually in the engine's flow map and will dispatch;
* `shadowed` are the ones that lost, in the order the registry listed them.
*/
export interface FlowShadowingRecord {
/** The bare name every contender claimed. */
name: string;
armed: FlowContender;
shadowed: FlowContender[];
}

export class AutomationEngine implements IAutomationService {
/**
* ADR-0044: maximum times a single node may be (re-)entered at the top
Expand All@@ -1250,6 +1278,22 @@ export class AutomationEngine implements IAutomationService {
static readonly MAX_NODE_REENTRIES = 100;

private flows = new Map<string, FlowParsed>();
/**
* [#11997] Shadowing receipts, keyed by the bare flow name.
*
* `flows` is keyed by BARE name and stays that way — making it
* package-aware is a much larger change than this defect warrants, and
* ADR-0048 does not ask for it. The consequence is that when a packaged
* flow and a runtime-authored flow claim one name, the loser leaves no
* trace in `flows`: `listFlows`/`getFlowRuntimeStates` enumerate
* `flows.keys()`, so the shadowed contender is invisible BY CONSTRUCTION,
* and an admin cannot tell which of the two is armed.
*
* This side map is the receipt. It never affects dispatch — it records
* what the ADR-0005 precedence decided, so {@link getShadowedFlows} and
* {@link getFlowRuntimeStates} can show it.
*/
private flowShadowing = new Map<string, FlowShadowingRecord>();
private flowEnabled = new Map<string, boolean>();
/**
* Re-entrancy guard for record-triggered flows (complements the intra-run
Expand DownExpand Up@@ -2596,16 +2640,25 @@ export class AutomationEngine implements IAutomationService {
status?: string;
triggerType?: string;
object?: string;
armedFrom?: FlowContender;
shadowed?: FlowContender[];
}> {
return [...this.flows.keys()].map((name) => {
const resolved = this.resolveTriggerBinding(name);
// [#11997] Attach the shadowing receipt to the row an admin already
// reads. This map holds ONE entry per bare name, so without these
// two fields a displaced contender leaves no trace on this surface.
const shadowing = this.flowShadowing.get(name);
return {
name,
enabled: this.flowEnabled.get(name) !== false,
bound: this.boundFlowTriggers.has(name),
status: (this.flows.get(name) as { status?: string } | undefined)?.status,
triggerType: resolved?.triggerType,
object: resolved?.binding.object,
...(shadowing
? { armedFrom: shadowing.armed, shadowed: shadowing.shadowed }
: {}),
};
});
}
Expand DownExpand Up@@ -2634,6 +2687,33 @@ export class AutomationEngine implements IAutomationService {
return audit;
}

/**
* [#11997] Record what the ADR-0005 overlay precedence decided for one bare
* name. Called by the boot pull when a name had more than one contender.
*
* Purely a receipt: it does not arm, disarm, or reorder anything. The pull
* has already registered the winner through the normal
* {@link registerFlow} path by the time this is called.
*/
recordFlowShadowing(record: FlowShadowingRecord): void {
if (!record.shadowed.length) return;
this.flowShadowing.set(record.name, record);
}

/**
* [#11997] Admin-visible receipt: every bare flow name that had more than
* one contender at boot, which body is armed, and which were shadowed.
*
* Empty when no name collided — the normal case. Without this the shadowed
* flow is unobservable: {@link listFlows} and {@link getFlowRuntimeStates}
* both enumerate a map that holds one entry per name by construction, so a
* packaged flow displaced by a same-named runtime overlay simply is not
* there to be listed.
*/
getShadowedFlows(): FlowShadowingRecord[] {
return [...this.flowShadowing.values()];
}

async listFlows(): Promise<string[]> {
return [...this.flows.keys()];
}
Expand Down
221 changes: 221 additions & 0 deletions packages/services/service-automation/src/flow-name-shadowing.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#11997] A runtime-authored flow reusing a packaged flow's name must not
// silently and non-deterministically replace it.
//
// MEASURED BEFORE THE FIX (this is what these tests were written against):
// with a real `SchemaRegistry` and a real `AutomationEngine`, registering the
// package first armed the RUNTIME body, and registering the runtime row first
// armed the PACKAGED body. Same two definitions, opposite outcomes, decided by
// nothing but Map iteration order — and `listFlows()` returned exactly one
// name in BOTH cases, so nothing observable distinguished them.
//
// Worse, in the ONE ordering where `Registry.registerItem` does emit its
// `[Registry] Collision` warning (a runtime row exists, then a package ships
// the name), the warning promises "the runtime row will shadow the package
// value" while the engine armed the PACKAGED body — the warning was actively
// contradicted by the thing it warned about.
//
// The direction asserted here is NOT chosen by this test. ADR-0048 §1.5 lists
// the runtime/DB overlay as "the sanctioned override path" and §3.4 routes it
// to "the ADR-0005 overlay precedence"; ADR-0005 states that precedence as
// `sys_metadata … ← overlay (wins)` over `SchemaRegistry … ← artifact default`.
// Runtime wins. If that direction is ever revisited, this file is one of the
// places the decision has to be re-argued — do not flip it to match code.

import { describe, it, expect, vi } from 'vitest';
import { SchemaRegistry } from '@objectstack/objectql';
import { AutomationEngine } from './engine.js';
import { resolveFlowPrecedence, describeFlowContender } from './flow-precedence.js';

const FLOW = 'opportunity_approval';
const silentLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any;

function flowBody(name: string, marker: string) {
return {
name,
label: marker,
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start', config: {} },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
};
}

const packagedBody = () => ({ ...flowBody(FLOW, 'PACKAGED'), _packageId: 'crm' });
const runtimeBody = () => ({ ...flowBody(FLOW, 'RUNTIME') });

/** A registry holding both contenders, registered in the given order. */
function registryWithBoth(order: 'package-first' | 'runtime-first') {
const registry: any = new SchemaRegistry();
if (order === 'package-first') {
registry.registerItem('flow', packagedBody(), 'name', 'crm');
registry.registerItem('flow', runtimeBody(), 'name');
} else {
registry.registerItem('flow', runtimeBody(), 'name');
registry.registerItem('flow', packagedBody(), 'name', 'crm');
}
return registry;
}

/**
* 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.
*/
function bootPull(registry: any, logger: { warn(m: string, meta?: unknown): void } = silentLogger) {
const engine = new AutomationEngine(silentLogger);
const listed = registry.listItems('flow') as unknown[];
const resolved = resolveFlowPrecedence(listed, logger);
for (const entry of resolved) {
engine.registerFlow(entry.name, entry.definition as never);
if (entry.shadowing) engine.recordFlowShadowing(entry.shadowing);
}
return { engine, listed, resolved };
}

describe('#11997 — packaged flow shadowed by a same-named runtime flow', () => {
it('the registry still returns BOTH contenders (ADR-0048 §3.4 coexistence is untouched)', () => {
// The fix must NOT dedup in the registry: two entries under one bare
// name is deliberate there, and package-scoped getItem depends on it.
for (const order of ['package-first', 'runtime-first'] as const) {
const listed = registryWithBoth(order).listItems('flow') as any[];
expect(listed).toHaveLength(2);
expect(listed.map((i) => i.label).sort()).toEqual(['PACKAGED', 'RUNTIME']);
}
});

it('arms the SAME definition regardless of registration order (Map order no longer decides)', async () => {
const first = bootPull(registryWithBoth('package-first'));
const second = bootPull(registryWithBoth('runtime-first'));

const armedFirst = (await first.engine.getFlow(FLOW)) as any;
const armedSecond = (await second.engine.getFlow(FLOW)) as any;

// Pre-fix these were 'RUNTIME' and 'PACKAGED' respectively.
expect(armedFirst?.label).toBe('RUNTIME');
expect(armedSecond?.label).toBe('RUNTIME');
expect(armedFirst?.label).toBe(armedSecond?.label);
});

it('arms the runtime overlay over the packaged artifact (ADR-0005 direction)', async () => {
const { engine } = bootPull(registryWithBoth('package-first'));
const armed = (await engine.getFlow(FLOW)) as any;
expect(armed?.label).toBe('RUNTIME');
expect(armed?._packageId).toBeUndefined();
});

it('registers ONE flow per bare name, not one per definition', async () => {
const { engine, listed, resolved } = bootPull(registryWithBoth('package-first'));
expect(listed).toHaveLength(2); // two candidates in
expect(resolved).toHaveLength(1); // one armed out
expect(await engine.listFlows()).toEqual([FLOW]);
});

it('warns loudly, naming the bare name, BOTH contenders, and which is armed', () => {
const warn = vi.fn();
bootPull(registryWithBoth('package-first'), { warn });

expect(warn).toHaveBeenCalledTimes(1);
const [message, meta] = warn.mock.calls[0] as [string, any];

expect(message).toContain(FLOW); // the bare name
expect(message).toContain('package "crm"'); // contender A
expect(message).toContain('runtime-authored row'); // contender B
expect(message).toContain('arming a runtime-authored row'); // which one wins
expect(message).toContain('ADR-0005');
// A single line: the boot diagnostic buffer keeps only the line
// carrying the level prefix (#5048).
expect(message).not.toContain('\n');

expect(meta.flow).toBe(FLOW);
expect(meta.armed).toEqual({ source: 'runtime' });
expect(meta.shadowed).toEqual([{ source: 'package', packageId: 'crm' }]);
});

it('leaves an admin-visible receipt for the shadowed definition', () => {
const { engine } = bootPull(registryWithBoth('package-first'));

// The dedicated audit, beside getTriggerBindingAudit.
expect(engine.getShadowedFlows()).toEqual([
{
name: FLOW,
armed: { source: 'runtime' },
shadowed: [{ source: 'package', packageId: 'crm' }],
},
]);

// …and on the row an admin already reads. Without these two fields the
// displaced definition is invisible: this map holds one entry per name.
const states = engine.getFlowRuntimeStates();
expect(states).toHaveLength(1);
expect(states[0].name).toBe(FLOW);
expect(states[0].armedFrom).toEqual({ source: 'runtime' });
expect(states[0].shadowed).toEqual([{ source: 'package', packageId: 'crm' }]);
});

it('says nothing and attaches nothing when a name has only one definition', () => {
const warn = vi.fn();
const registry: any = new SchemaRegistry();
registry.registerItem('flow', packagedBody(), 'name', 'crm');
const { engine, resolved } = bootPull(registry, { warn });

expect(warn).not.toHaveBeenCalled();
expect(resolved).toHaveLength(1);
expect(engine.getShadowedFlows()).toEqual([]);
const [state] = engine.getFlowRuntimeStates();
expect(state.armedFrom).toBeUndefined();
expect(state.shadowed).toBeUndefined();
});
});

describe('#11997 — precedence is a total order, not an iteration order', () => {
it('two packages shipping one bare name resolve by packageId, either way round', () => {
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);

expect((forward[0].definition as any).label).toBe('FROM_ALPHA');
expect((backward[0].definition as any).label).toBe('FROM_ALPHA');
expect(forward[0].shadowing?.shadowed).toEqual([{ source: 'package', packageId: 'beta' }]);
});

it('preserves first-seen order for the non-colliding names around a collision', () => {
const before = { ...flowBody('aaa_first', 'A') };
const after = { ...flowBody('zzz_last', 'Z') };
const resolved = resolveFlowPrecedence(
[before, packagedBody(), after, runtimeBody()],
silentLogger,
);
expect(resolved.map((r) => r.name)).toEqual(['aaa_first', FLOW, 'zzz_last']);
});

it('classifies the sys_metadata rehydration sentinel as runtime, not as a package', () => {
// `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({
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' }),
).toEqual({ source: 'runtime', packageId: 'app.built_by_studio' });
expect(describeFlowContender({ name: FLOW, _packageId: 'crm' })).toEqual({
source: 'package',
packageId: 'crm',
});
});

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);
expect((resolved[0].definition as any).label).toBe('TENANT');
});
});
Loading
Loading