Skip to content

Commit 951e03e

Browse files
committed
test(service-automation): pin precedence against an explicit loader's set
Tenant-ranked contenders keep arrival order (the id tie-break stays within the packaged rank, as the precedence docblock states); the #11997 suite hands precedence the registry's artifact-view owner the way the boot pull does; a new suite pins the classification, the shadowing record, the fail-closed composition and the plugin wiring. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 42ee06c commit 951e03e

3 files changed

Lines changed: 289 additions & 10 deletions

File tree

‎packages/services/service-automation/src/flow-name-shadowing.test.ts‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ function flowBody(name: string, marker: string) {
4747
const packagedBody = () => ({ ...flowBody(FLOW, 'PACKAGED'), _packageId: 'crm' });
4848
const runtimeBody = () => ({ ...flowBody(FLOW, 'RUNTIME') });
4949

50+
/**
51+
* [#20864] The loader's set, read the way the metadata protocol's
52+
* `packagedArtifactOwner` reads it: the owner of the registry's ARTIFACT view
53+
* of `name` (`SchemaRegistry.getArtifactItem`), never a listed body's stamps.
54+
* Precedence classifies by this set (see `flow-precedence-loader-set.test.ts`),
55+
* so a suite about the ORDER it imposes hands it one, as the boot pull does.
56+
*/
57+
function loaderSetOf(registry: any) {
58+
return (name: string): string | undefined => {
59+
const owner = (registry.getArtifactItem('flow', name) as { _packageId?: unknown } | undefined)?._packageId;
60+
return typeof owner === 'string' && owner !== '' ? owner : undefined;
61+
};
62+
}
63+
64+
/** An explicit loader's set, for the list-only cases below. */
65+
const setOf = (set: Record<string, string>) => (name: string): string | undefined => set[name];
66+
5067
/** A registry holding both contenders, registered in the given order. */
5168
function registryWithBoth(order: 'package-first' | 'runtime-first') {
5269
const registry: any = new SchemaRegistry();
@@ -62,12 +79,15 @@ function registryWithBoth(order: 'package-first' | 'runtime-first') {
6279

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

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

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

203226
it('classifies the sys_metadata rehydration sentinel as runtime, not as a package', () => {
227+
// Judged with the loader's set HOLDING the name, so what is pinned is
228+
// the per-entry test inside a held name, not the set's own answer.
229+
const loaderSet = setOf({ [FLOW]: 'crm' });
204230
// `loadMetaFromDb` rehydrates overlay rows with a synthetic
205231
// `_packageId = 'sys_metadata'` (ADR-0005 §Provenance edge case). A
206232
// bare `_packageId` truthiness test would misread it as packaged.
207-
expect(describeFlowContender({ name: FLOW, _packageId: 'sys_metadata' })).toEqual({
233+
expect(describeFlowContender({ name: FLOW, _packageId: 'sys_metadata' }, loaderSet)).toEqual({
208234
source: 'runtime',
209235
packageId: 'sys_metadata',
210236
});
211237
// And a tenant-authored overlay bound to a REAL package id is runtime
212238
// too — provenance is the axis, not the id (cloud#970).
213239
expect(
214-
describeFlowContender({ name: FLOW, _packageId: 'app.built_by_studio', _provenance: 'org' }),
240+
describeFlowContender({ name: FLOW, _packageId: 'app.built_by_studio', _provenance: 'org' }, loaderSet),
215241
).toEqual({ source: 'runtime', packageId: 'app.built_by_studio' });
216-
expect(describeFlowContender({ name: FLOW, _packageId: 'crm' })).toEqual({
242+
expect(describeFlowContender({ name: FLOW, _packageId: 'crm' }, loaderSet)).toEqual({
217243
source: 'package',
218244
packageId: 'crm',
219245
});
@@ -222,7 +248,7 @@ describe('#11997 — precedence is a total order, not an iteration order', () =>
222248
it('a tenant overlay beats the package even when both carry a real package id', () => {
223249
const packaged = { ...flowBody(FLOW, 'PACKAGED'), _packageId: 'crm' };
224250
const tenant = { ...flowBody(FLOW, 'TENANT'), _packageId: 'crm', _provenance: 'org' };
225-
const resolved = resolveFlowPrecedence([packaged, tenant], silentLogger);
251+
const resolved = resolveFlowPrecedence([packaged, tenant], silentLogger, setOf({ [FLOW]: 'crm' }));
226252
expect((resolved[0].definition as any).label).toBe('TENANT');
227253
});
228254
});
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#20864, ADR-0126 §2 / §7.3] Boot-time flow precedence classifies its
4+
// contenders by THE LOADER'S SET — the reader the engine holds
5+
// (`setPackagedFlowSource` / `packagedFlowOwner`) — and never by the stamps a
6+
// flow body carries.
7+
//
8+
// For a flow, "packaged" means exactly "loaded by the loader from a managed
9+
// package", and every classification the engine makes reads that one
10+
// server-held fact. The §7.3 guards, the arming gate and the activation door
11+
// were moved onto it first (`packaged-flow-source.test.ts`); this file pins the
12+
// last reader, the precedence that decides which same-named body the boot pull
13+
// arms. Every pin hands precedence an EXPLICIT set and reads the answer off the
14+
// classification, the shadowing record and the armed body — and the last block
15+
// boots the real plugin, so the wiring from the metadata protocol's answer to
16+
// the boot pull is pinned too, not only the pure function.
17+
18+
import { describe, it, expect, vi } from 'vitest';
19+
import { LiteKernel } from '@objectstack/core';
20+
import type { Plugin, PluginContext } from '@objectstack/core';
21+
import { AutomationServicePlugin } from './plugin.js';
22+
import type { AutomationEngine, FlowContender } from './engine.js';
23+
import { resolveFlowPrecedence, describeFlowContender, renderFlowContender } from './flow-precedence.js';
24+
25+
const FLOW = 'order_sync';
26+
27+
function flowBody(name: string, label: string, extra: Record<string, unknown> = {}) {
28+
return {
29+
name,
30+
label,
31+
type: 'autolaunched',
32+
nodes: [
33+
{ id: 'start', type: 'start', label: 'Start', config: {} },
34+
{ id: 'end', type: 'end', label: 'End' },
35+
],
36+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
37+
...extra,
38+
};
39+
}
40+
41+
/** The stamps a body carries when it claims a package's provenance. */
42+
const CLAIMED = { _packageId: 'crm', _provenance: 'package' };
43+
/** What the boot hydration leaves on a stored row: the tenant marker, here with a binding. */
44+
const TENANT = { _packageId: 'app.ops', _provenance: 'org' };
45+
46+
/** The loader's entry for FLOW, as the registry holds it once the loader stamped it. */
47+
const loaderEntry = () => flowBody(FLOW, 'LOADER', CLAIMED);
48+
/** A stored tenant row of the same name, as the hydration registers it. */
49+
const tenantRow = () => flowBody(FLOW, 'TENANT', TENANT);
50+
51+
/** An explicit loader's set: flow name → the package that loaded it. */
52+
const setOf = (set: Record<string, string>) => (name: string): string | undefined => set[name];
53+
const HOLDS_FLOW = setOf({ [FLOW]: 'crm' });
54+
const HOLDS_NOTHING = setOf({});
55+
56+
const silent = () => ({ warn: vi.fn() });
57+
58+
describe('[#20864] boot-time precedence classifies by the loader\'s set, not by body stamps', () => {
59+
it('a contender claiming a package ranks as tenant-authored when the set does not hold its name', () => {
60+
const claimed = loaderEntry();
61+
expect(describeFlowContender(claimed, HOLDS_NOTHING)).toEqual({ source: 'runtime', packageId: 'crm' });
62+
// The set is keyed by NAME: holding another flow vouches for nothing here.
63+
expect(describeFlowContender(claimed, setOf({ other_flow: 'crm' }))).toEqual({
64+
source: 'runtime',
65+
packageId: 'crm',
66+
});
67+
// The operator reads it as the tenant row it is; the claimed package
68+
// never reaches the sentence.
69+
expect(renderFlowContender(describeFlowContender(claimed, HOLDS_NOTHING))).toBe(
70+
renderFlowContender({ source: 'runtime' }),
71+
);
72+
});
73+
74+
it('the same contender ranks as packaged when the set holds its name', () => {
75+
expect(describeFlowContender(loaderEntry(), HOLDS_FLOW)).toEqual({ source: 'package', packageId: 'crm' });
76+
});
77+
78+
it('a stored tenant row stays tenant-authored inside a name the set holds', () => {
79+
expect(describeFlowContender(tenantRow(), HOLDS_FLOW)).toEqual({ source: 'runtime', packageId: 'app.ops' });
80+
});
81+
82+
it('the same two bodies rank by the set: only a held name puts a packaged contender in the shadowing record', () => {
83+
const held = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_FLOW);
84+
expect(held).toHaveLength(1);
85+
expect((held[0].definition as { label: string }).label).toBe('TENANT');
86+
expect(held[0].shadowing).toEqual({
87+
name: FLOW,
88+
armed: { source: 'runtime', packageId: 'app.ops' },
89+
shadowed: [{ source: 'package', packageId: 'crm' }],
90+
});
91+
92+
const unheld = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING);
93+
expect(unheld[0].shadowing).toEqual({
94+
name: FLOW,
95+
armed: { source: 'runtime', packageId: 'app.ops' },
96+
shadowed: [{ source: 'runtime', packageId: 'crm' }],
97+
});
98+
});
99+
100+
it('the shadowing record names the armed body in either arrival order, and the pull warning agrees', () => {
101+
for (const listed of [
102+
[loaderEntry(), tenantRow()],
103+
[tenantRow(), loaderEntry()],
104+
]) {
105+
const logger = silent();
106+
const [winner] = resolveFlowPrecedence(listed, logger, HOLDS_FLOW);
107+
expect((winner.definition as { label: string }).label).toBe('TENANT');
108+
expect(winner.shadowing?.armed).toEqual({ source: 'runtime', packageId: 'app.ops' });
109+
expect(winner.shadowing?.shadowed).toEqual([{ source: 'package', packageId: 'crm' }]);
110+
111+
expect(logger.warn).toHaveBeenCalledTimes(1);
112+
const [message, meta] = logger.warn.mock.calls[0] as [string, { armed: FlowContender; shadowed: FlowContender[] }];
113+
expect(message).toContain(`arming ${renderFlowContender({ source: 'runtime' })}`);
114+
expect(message).toContain(renderFlowContender({ source: 'package', packageId: 'crm' }));
115+
expect(meta.armed).toEqual(winner.shadowing?.armed);
116+
expect(meta.shadowed).toEqual(winner.shadowing?.shadowed);
117+
}
118+
});
119+
120+
it('tenant-ranked contenders keep arrival order: a body\'s own id does not order them', () => {
121+
// Both tenant-ranked (the set holds nothing). By id alone the tenant
122+
// row would sort first; arrival order decides instead, so a claimed id
123+
// cannot buy the armed slot.
124+
const first = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_NOTHING);
125+
expect((first[0].definition as { label: string }).label).toBe('LOADER');
126+
const second = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING);
127+
expect((second[0].definition as { label: string }).label).toBe('TENANT');
128+
});
129+
130+
it('with no reader nothing is packaged: the engine\'s fail-closed answer', () => {
131+
expect(describeFlowContender(loaderEntry())).toEqual({ source: 'runtime', packageId: 'crm' });
132+
133+
const [winner] = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent());
134+
const record = winner.shadowing!;
135+
expect([record.armed, ...record.shadowed].map((c) => c.source)).toEqual(['runtime', 'runtime']);
136+
137+
// A reader answering an empty owner names no package either — the
138+
// normalization `AutomationEngine.packagedFlowOwner` applies.
139+
expect(describeFlowContender(loaderEntry(), () => '')).toEqual({ source: 'runtime', packageId: 'crm' });
140+
});
141+
142+
it('asks the set once per contested name, and never for an uncontested one', () => {
143+
const reader = vi.fn(HOLDS_FLOW);
144+
resolveFlowPrecedence([flowBody('lonely_flow', 'L', CLAIMED), loaderEntry(), tenantRow()], silent(), reader);
145+
expect(reader.mock.calls).toEqual([[FLOW]]);
146+
});
147+
});
148+
149+
// ── The boot pull, through the real plugin ─────────────────────────────────
150+
151+
/** The one seam the boot pull reads — `registry.listItems('flow')`. */
152+
function fakeObjectqlPlugin(flows: unknown[]): Plugin {
153+
return {
154+
name: 'fake-objectql',
155+
version: '1.0.0',
156+
async init(ctx: PluginContext) {
157+
(ctx as unknown as { registerService(n: string, s: unknown): void }).registerService('objectql', {
158+
registry: {
159+
listItems: (type: string) => (type === 'flow' ? flows : []),
160+
getObject: () => undefined,
161+
},
162+
});
163+
},
164+
};
165+
}
166+
167+
/**
168+
* The metadata protocol, reduced to the loader's-set read the plugin's
169+
* `packagedFlowReader` asks, plus an empty execution view so the kernel:ready
170+
* sync registers nothing over the pull under test.
171+
*/
172+
function fakeProtocolPlugin(packagedArtifactOwner: (request: { type: string; name: string }) => string | undefined): Plugin {
173+
return {
174+
name: 'fake-protocol',
175+
version: '1.0.0',
176+
async init(ctx: PluginContext) {
177+
(ctx as unknown as { registerService(n: string, s: unknown): void }).registerService('protocol', {
178+
packagedArtifactOwner,
179+
async getMetaItemsForExecution() {
180+
return { items: [] };
181+
},
182+
});
183+
},
184+
};
185+
}
186+
187+
async function boot(flows: unknown[], protocol?: Plugin) {
188+
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
189+
kernel.use(fakeObjectqlPlugin(flows));
190+
if (protocol) kernel.use(protocol);
191+
kernel.use(new AutomationServicePlugin());
192+
await kernel.bootstrap();
193+
return { kernel, engine: kernel.getService<AutomationEngine>('automation') };
194+
}
195+
196+
describe('[#20864] the boot pull hands precedence the engine\'s loader\'s-set reader', () => {
197+
it('a held name: the shadowing receipt names the tenant row armed and the loader entry packaged', async () => {
198+
const owner = vi.fn((request: { type: string; name: string }) =>
199+
request.type === 'flow' && request.name === FLOW ? 'crm' : undefined,
200+
);
201+
const { kernel, engine } = await boot([loaderEntry(), tenantRow()], fakeProtocolPlugin(owner));
202+
try {
203+
expect(engine.getShadowedFlows()).toEqual([
204+
{
205+
name: FLOW,
206+
armed: { source: 'runtime', packageId: 'app.ops' },
207+
shadowed: [{ source: 'package', packageId: 'crm' }],
208+
},
209+
]);
210+
expect(((await engine.getFlow(FLOW)) as { label?: string } | null)?.label).toBe('TENANT');
211+
// One source: the pull asked the protocol for this flow, and the
212+
// engine's own reader gives the same answer.
213+
expect(owner).toHaveBeenCalledWith({ type: 'flow', name: FLOW });
214+
expect(engine.packagedFlowOwner(FLOW)).toBe('crm');
215+
} finally {
216+
await kernel.shutdown();
217+
}
218+
});
219+
220+
it('an unheld name: no contender is packaged, whatever the bodies claim', async () => {
221+
const { kernel, engine } = await boot([tenantRow(), loaderEntry()], fakeProtocolPlugin(() => undefined));
222+
try {
223+
const [record] = engine.getShadowedFlows();
224+
expect(record.name).toBe(FLOW);
225+
expect([record.armed, ...record.shadowed].map((c) => c.source)).toEqual(['runtime', 'runtime']);
226+
} finally {
227+
await kernel.shutdown();
228+
}
229+
});
230+
231+
it('no protocol service: fail closed, no contender is packaged', async () => {
232+
const { kernel, engine } = await boot([loaderEntry(), tenantRow()]);
233+
try {
234+
const [record] = engine.getShadowedFlows();
235+
expect([record.armed, ...record.shadowed].map((c) => c.source)).toEqual(['runtime', 'runtime']);
236+
} finally {
237+
await kernel.shutdown();
238+
}
239+
});
240+
});

‎packages/services/service-automation/src/flow-precedence.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,13 @@ function precedenceRank(contender: FlowContender): number {
216216
* boot order does. That case is warned about too — it is exactly as invisible as
217217
* the artifact-vs-DB one.
218218
*
219+
* Two `runtime` contenders tie on both rules and keep arrival order. The boot
220+
* hydration registers every stored row under the one bare-name slot, so the
221+
* stored rows bring at most one tenant row per name into this list. Without a
222+
* reader nothing is packaged, so two packages shipping one bare name tie the
223+
* same way — the fail-closed composition gives up rule 2 rather than rank by
224+
* a body's own claim.
225+
*
219226
* [#20864] Which contender is `package` is the loader's set's answer, asked
220227
* ONCE per contested name so every contender of that name is judged against
221228
* one answer — see {@link describeFlowContender}. A name with one contender
@@ -262,8 +269,14 @@ export function resolveFlowPrecedence(
262269
const ranked = [...group].sort((a, b) => {
263270
const byRank = precedenceRank(a.contender) - precedenceRank(b.contender);
264271
if (byRank !== 0) return byRank;
265-
const byPackage = (a.contender.packageId ?? '').localeCompare(b.contender.packageId ?? '');
266-
if (byPackage !== 0) return byPackage;
272+
// [#20864] Rule 2 is WITHIN `package` only, as stated above. A
273+
// `runtime` contender's id is the body's own bytes, kept for
274+
// display — letting it order tenant rows would hand the armed
275+
// slot back to the stamps the loader's set just stopped trusting.
276+
if (a.contender.source === 'package') {
277+
const byPackage = (a.contender.packageId ?? '').localeCompare(b.contender.packageId ?? '');
278+
if (byPackage !== 0) return byPackage;
279+
}
267280
// Fully-tied bodies: keep arrival order so the result is still total.
268281
return a.index - b.index;
269282
});

0 commit comments

Comments
 (0)