Skip to content

Commit 27bf358

Browse files
fix(service-automation): boot-time flow precedence classifies contenders by the loader's set (#20880)
Fixes #20864 Clause-②: yes (widening) Boot-time flow precedence now takes which same-named contender is the packaged one from the loader's set, the reader the engine has held since PR #20853, instead of from the flow bodies' package-provenance stamps. This finishes rule 1 of the ruling recorded on #20761 (`5904938166`) for the last reader it names, precedence. `packages/services/service-automation` is a **declared cross-lane touch** (`domain:services`) from the `domain:cli` seat, as it was on #20761. Base `72f8c3820` (claim), head `6a028afb2b`. `main` was not merged in. ## What changed - **`flow-precedence.ts`.** `resolveFlowPrecedence(items, logger?, packagedFlowOwner?)` and `describeFlowContender(item, packagedFlowOwner?)` now take the reader as an optional last argument. Its type is `PackagedFlowSource`, the one `AutomationEngine.setPackagedFlowSource` already takes. - A contender is packaged only when the set holds its name. - Inside a name the set holds, the loader's entries are told apart from a same-named tenant row by the registry's own per-entry artifact test (`isCodeArtifactBody`). The set's own lookup (`SchemaRegistry.getArtifactItem`) applies that same test to each entry. - With no reader, nothing is packaged. This is the engine's fail-closed answer. - The set is asked once for each contested name. A name with one contender asks nothing. - **Tie-break.** The package-id tie-break now applies within the packaged rank only, as the function's docblock always said. Two tenant-ranked contenders keep their arrival order, so a body's own id cannot win it the armed slot. - **`plugin.ts`.** The one call site passes the engine's own `packagedFlowOwner`, which `packagedFlowReader` has fed since `init()`. Precedence and every other classification the engine makes now read one source. There is no second set. - **Changeset.** `@objectstack/service-automation` gets a `minor` bump, and the changeset carries the one-line migration for direct callers. ## Clause-② reading `yes (widening)`: two barrel-exported functions gain an optional parameter. The claim declared `no` on the condition that no public member is added. A new accepted argument widens the public signature, so this line is re-declared, as the claim instructs. No accept set shrinks. A body's claim of package provenance was never a declared input to precedence. The stamps are server-derived read decorations, which is the same negative boundary the PR #20853 record judged for this rule. ## Premise, measured before the change (at `72f8c3820`) These runs used a scratch vitest file (not committed) and a real `SchemaRegistry` from `@objectstack/objectql`. - **M1.** A body stamped with a package's provenance, for a name no set holds, was classified `package`. - **M2.** On pure input, a forged package id beside the loader's entry took the armed slot by lexicographic order. - **M3.** A real registry was filled with the loader's shapes and the hydration's shapes (the tenant marker, and the artifact graft). Every code-shaped listed entry had its name in the set: the invariant held for all 4 code-shaped entries among the 6 listed. So, as the PR #20853 record read, this is not reachable from an authoring door at boot. The change is defence in depth and completes the ruling. The same file after the change (at `6a028afb2b`): - M1 classifies the stamped body as `runtime`. - M3 arms the same flows with the same receipts as before, for the shapes measured both times: - the grafted overlay's name arms the loader entry, by arrival order; - `alpha` wins over `beta`. - A plain overlay beside a loader entry still wins. That is the #11997 suite's case, and its expectations are unchanged. ## Tests (at `6a028afb2b`) - **`pnpm --filter @objectstack/service-automation test`:** 159 files, 1995 tests passed. - **`pnpm --filter @objectstack/service-automation typecheck`:** `tsc` is clean. `check:test-typecheck` is OK: the test layer compiles, with 0 debt. - **New `flow-precedence-loader-set.test.ts` (11 pins):** - A stamped contender ranks tenant-authored when the set does not hold its name, and the same contender ranks packaged when the set holds it. - A stored tenant row stays tenant-authored inside a held name. - The same two bodies rank by the set, not by their bytes. - The shadowing record and the pull warning name the armed body in both arrival orders. - Tenant-ranked contenders keep arrival order. - With no reader, the classification fails closed. A reader that answers an empty owner fails closed too. - Each contested name is asked about once. - The real `AutomationServicePlugin` boots three times: a held name (answered by a protocol stand-in's `packagedArtifactOwner`), an unheld name, and no protocol service at all. - **`flow-name-shadowing.test.ts` (#11997):** precedence now gets the registry's artifact-view owner. The owner is attached to the engine and read back through `packagedFlowOwner`, exactly as the pull does. The expectations are unchanged. - **Dogfood regression, `flow-provenance-server-held.dogfood.test.ts` (PR #20853's suite):** 13 of 13 passed against a rebuilt `dist/`. The dist marker count is 1 for each of the two changed spots. ## Ablations The fix was committed first. Each mutation ran through `scripts/ablation-replace.mjs`. For every one, the anchor went from 1 hit to 0, the blob changed, the restored blob equals `HEAD`, and `git diff HEAD` is empty. The subject resolves by relative import from `src/`, so `dist/` is not on the path. - **A1, the classifier ignores the set (the stamps decide again):** 6 of the 11 pins go red, 4 pure and 2 plugin boots. - **A2, the boot pull stops passing the reader:** 1 pin goes red, the held-name plugin boot. - **A3, the id tie-break orders tenant-ranked contenders again:** 1 pin goes red, the arrival-order pin. All three went red in the predicted direction. ## Gates (at `6a028afb2b`) - **`dispatch-gates --commands`,** run with no paths: the change set was derived from merge base `72f8c3820` and covers 5 paths. That gave 61 commands. Every one ran, and every one exited 0. - `--ran` with exit codes: 61 derived, 61 run, 0 NOT MEASURED, 0 unrun. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET, because 8 packages were not built in this worktree. After they were built (all turbo cache hits), it exited 0. - **Beyond that list:** `check:startup-registry-verdict` and `check:durability-log-level` are green. - **Lint, as a proven narrowing rather than `pnpm lint`:** 1. **The population comes from eslint's own config.** `--print-config` resolves the lint config for each of the 4 changed TypeScript files (6 rules for the source files, 5 for the tests), and none is ignored. The changeset falls outside the config's TypeScript/JavaScript `files`. 2. **The count comes from `--format json`:** 4 files, 0 errors, 0 warnings. 3. **Untouched files cannot change verdict.** No file's resolved config sets `parserOptions.project` or `projectService`, so type-aware linting is off. The custom rules read only the linted AST, plus a config-level baseline that this diff does not touch. ## Acceptance notes 1. **Per-name limit.** The reader answers per name. Inside a held name, a code-shaped entry that is not the loader's (a forged package id with no tenant marker) still ranks packaged under its own id. It can win the lexicographic order (M2, after the change). It is not reachable at boot: every code-shaped registry entry is either the loader's or carries the artifact's own grafted envelope. Closing it needs a per-entry answer from the metadata protocol, which is outside this card's surface. Carrier: none. 2. **Grafted stored rows.** A stored row of a held name hydrates with the artifact's envelope grafted onto it, so it ranks packaged beside the loader entry. Kernel phase order (the loader comes first) then arms the loader body, and the receipt lists that package twice. This PR does not change that: M3 was identical before and after. ADR-0126 §7.1 leaves the shadow diagnostics to #11997. 3. **Stale docblock.** `FlowContender`'s docblock in `engine.ts` still describes `package` as the per-body artifact test alone. That file is outside this card's surface. Carrier: the next PR that touches that type. 4. **No-reader compositions.** In a composition with no reader, two packages that ship one bare flow name now keep arrival order instead of package-id order, because nothing is packaged there. Every real composition attaches the reader. Session: `session_01VvcEokUG1tvVxkceYfR5XB`, branch `claude/issue-20864-precedence-loader-set`. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f5c7b2c commit 27bf358

5 files changed

Lines changed: 407 additions & 34 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
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)
6+
7+
Clause-②: yes (widening)
8+
9+
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.
10+
11+
- `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.
12+
- 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.
13+
- With no reader, no contender is packaged. That is the engine's own answer when no reader is attached.
14+
- 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.
15+
- 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.
16+
17+
**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.

‎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+
});

0 commit comments

Comments
 (0)