From 42ee06c9e2683aab3c23b9da6b2331473725ac78 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 14:02:07 +0000 Subject: [PATCH 1/3] fix(service-automation): boot-time flow precedence classifies contenders by the loader's set resolveFlowPrecedence and describeFlowContender take the loader's-set reader (PackagedFlowSource); the boot pull hands it the engine's own packagedFlowOwner. A contender is packaged only when the set holds its name; with no reader nothing is packaged (the engine's fail-closed answer). Body stamps stay on the contender for display only. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../service-automation/src/flow-precedence.ts | 117 ++++++++++++++---- .../services/service-automation/src/plugin.ts | 8 +- 2 files changed, 101 insertions(+), 24 deletions(-) diff --git a/packages/services/service-automation/src/flow-precedence.ts b/packages/services/service-automation/src/flow-precedence.ts index 4da74e19670..2d37e5a44ff 100644 --- a/packages/services/service-automation/src/flow-precedence.ts +++ b/packages/services/service-automation/src/flow-precedence.ts @@ -44,6 +44,17 @@ // ⛔ NOT DONE HERE: making the engine's flow map package-aware. That is a much // larger change, and ADR-0048 does not ask for it — the ADR's answer for this // case is a precedence plus a warning, both of which are here. +// +// [#20864, ADR-0126 §2 / §7.3] WHICH CONTENDER IS PACKAGED IS THE LOADER'S SET. +// +// For a flow, "packaged" means exactly "loaded by the loader from a managed +// package", and the engine's classification — the §7.3 guards, the toggle +// door, and this precedence — reads that one server-held fact, never the +// stamps a flow body carries. The caller hands the reader in (the boot pull +// passes the engine's own `packagedFlowOwner`, so precedence and every other +// reader ask one source); with none, NO contender is packaged, which is the +// engine's fail-closed answer too. The body's stamps stay on the contender +// for display only. // [commit fa5d137ab] From `@objectstack/metadata-core`, which this package DECLARES — // not from `@objectstack/objectql`, which it does not. The predicate is the @@ -53,7 +64,7 @@ // answered it by inlining objectql's implementation into this package's dist. // `pnpm check:undeclared-dep-imports` is the gate over that class. import { isCodeArtifactBody } from '@objectstack/metadata-core'; -import type { FlowContender, FlowShadowingRecord } from './engine.js'; +import type { FlowContender, FlowShadowingRecord, PackagedFlowSource } from './engine.js'; /** One flow name's resolved winner, plus the receipt when it displaced others. */ export interface FlowPrecedenceWinner { @@ -64,6 +75,55 @@ export interface FlowPrecedenceWinner { shadowing?: FlowShadowingRecord; } +/** + * [#20864] The package the loader's set names for `name`, or `undefined` — + * including for every name when no reader was handed in. The same + * normalization `AutomationEngine.packagedFlowOwner` applies to the reader's + * answer: only a non-empty string names an owner. + */ +function loaderSetOwner(packagedFlowOwner: PackagedFlowSource | undefined, name: unknown): string | undefined { + if (!packagedFlowOwner || typeof name !== 'string' || name === '') return undefined; + const owner = packagedFlowOwner(name); + return typeof owner === 'string' && owner !== '' ? owner : undefined; +} + +/** + * Classify one contender, given what the loader's set answered for its name. + * + * Two questions, in this order, and only the first is the set's: + * + * 1. **Does the loader's set hold this name at all?** No ⇒ `runtime` + * (tenant-authored), whatever the body carries. A package id or a + * package provenance on the body is the caller's bytes, and a flow reaches + * a registry through authoring doors too — so a stamp can never put a + * contender into the packaged rank for a name no managed package loaded. + * 2. **Which of a held name's entries is the loader's?** The reader answers + * per NAME, and a held name can legitimately have several entries: a + * second package shipping the same bare name (ADR-0048 §3.4), and a + * tenant row of that name. Telling them apart is the registry's own + * per-entry test, `isCodeArtifactBody` — the exact test the set's lookup + * (`SchemaRegistry.getArtifactItem`) applies to each entry it considers. + * So on this side a stamp can only keep a held name's entry OUT of the + * packaged rank (the tenant marker the boot hydration forces onto every + * stored row), never admit one. + * + * A packaged contender keeps its own registered id: for one package that is + * the id the set names, and for two packages shipping one bare name it is what + * keeps the lexicographic order in {@link resolveFlowPrecedence} total. A + * `runtime` contender keeps a body's id for display only — + * {@link renderFlowContender} never prints it. + */ +function classifyContender(item: unknown, owner: string | undefined): FlowContender { + const packageId = (item as { _packageId?: unknown } | null | undefined)?._packageId; + if (owner !== undefined && isCodeArtifactBody(item)) { + return { source: 'package', packageId: String(packageId) }; + } + return { + source: 'runtime', + ...(typeof packageId === 'string' && packageId ? { packageId } : {}), + }; +} + /** * Classify one registry body's provenance. * @@ -75,22 +135,20 @@ export interface FlowPrecedenceWinner { * reusable had nothing in it to reuse. For the operator-facing phrase, use * {@link renderFlowContender} below. * - * Delegates to `isCodeArtifactBody` — the canonical ADR-0029 D9.6 test, which - * exists precisely so callers cannot drift into a second answer to "does a code - * package ship this name?". ⛔ Do not re-derive this from `_packageId`: that - * sentinel test cannot tell a tenant-authored overlay from a code artifact, - * because a tenant overlay bound to a package carries a real package id too - * (see `isTenantAuthored` in objectql's registry.ts, and cloud#970). + * [#20864, ADR-0126 §2 / §7.3] `packaged` is decided by THE LOADER'S SET — + * `packagedFlowOwner`, the reader `AutomationEngine.setPackagedFlowSource` + * takes (a host passes `engine.packagedFlowOwner`, so this and the engine's + * own classification ask one source). With no reader, nothing is packaged: + * the engine's fail-closed answer, never the body's own claim. Within a name + * the set holds, the loader's entries are told from a same-named tenant row + * by `isCodeArtifactBody`, the canonical ADR-0029 D9.6 test the set's lookup + * itself applies; ⛔ do not re-derive that from `_packageId` alone, which + * cannot tell a tenant overlay bound to a package from a code artifact (see + * `isTenantAuthored` in `@objectstack/metadata-core`, and cloud#970). */ -export function describeFlowContender(item: unknown): FlowContender { - const packageId = (item as { _packageId?: unknown } | null | undefined)?._packageId; - if (isCodeArtifactBody(item)) { - return { source: 'package', packageId: String(packageId) }; - } - return { - source: 'runtime', - ...(typeof packageId === 'string' && packageId ? { packageId } : {}), - }; +export function describeFlowContender(item: unknown, packagedFlowOwner?: PackagedFlowSource): FlowContender { + const name = (item as { name?: unknown } | null | undefined)?.name; + return classifyContender(item, loaderSetOwner(packagedFlowOwner, name)); } /** @@ -158,15 +216,23 @@ function precedenceRank(contender: FlowContender): number { * boot order does. That case is warned about too — it is exactly as invisible as * the artifact-vs-DB one. * - * @param items whatever `registry.listItems('flow')` returned - * @param logger warned once per colliding name, naming both contenders + * [#20864] Which contender is `package` is the loader's set's answer, asked + * ONCE per contested name so every contender of that name is judged against + * one answer — see {@link describeFlowContender}. A name with one contender + * asks nothing: precedence decides nothing there. + * + * @param items whatever `registry.listItems('flow')` returned + * @param logger warned once per colliding name, naming both contenders + * @param packagedFlowOwner the loader's set — the boot pull passes the engine's + * `packagedFlowOwner`; absent, no contender is packaged */ export function resolveFlowPrecedence( items: readonly unknown[], logger?: { warn(message: string, meta?: unknown): void }, + packagedFlowOwner?: PackagedFlowSource, ): FlowPrecedenceWinner[] { // Group by bare name, remembering arrival order for a stable tie-break. - const groups = new Map>(); + const groups = new Map>(); const order: string[] = []; items.forEach((item, index) => { const name = (item as { name?: unknown } | null | undefined)?.name; @@ -177,17 +243,22 @@ export function resolveFlowPrecedence( groups.set(name, group); order.push(name); } - group.push({ definition: item, contender: describeFlowContender(item), index }); + group.push({ definition: item, index }); }); const winners: FlowPrecedenceWinner[] = []; for (const name of order) { - const group = groups.get(name)!; - if (group.length === 1) { - winners.push({ name, definition: group[0].definition }); + const members = groups.get(name)!; + if (members.length === 1) { + winners.push({ name, definition: members[0].definition }); continue; } + const owner = loaderSetOwner(packagedFlowOwner, name); + const group = members.map((member) => ({ + ...member, + contender: classifyContender(member.definition, owner), + })); const ranked = [...group].sort((a, b) => { const byRank = precedenceRank(a.contender) - precedenceRank(b.contender); if (byRank !== 0) return byRank; diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index fbc5c125eef..0cbd6808a8a 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -1017,7 +1017,13 @@ export class AutomationServicePlugin implements Plugin { // resolveFlowPrecedence applies the ADR-0005 direction — runtime // overlay wins over the packaged artifact — and warns per colliding // name, which is the artifact-vs-DB warning ADR-0048 §3.4 routes to. - const resolved = resolveFlowPrecedence(flows, ctx.logger); + // [#20864, ADR-0126 §7.3] Which contender IS the packaged artifact + // is the loader's set, asked through the engine's own + // `packagedFlowOwner` (the `packagedFlowReader` attached in + // `init()`), so precedence and every other classification the + // engine makes read one source — never the stamps on the bodies. + const engine = this.engine; + const resolved = resolveFlowPrecedence(flows, ctx.logger, (name) => engine.packagedFlowOwner(name)); const shadowedNames = resolved.filter((entry) => entry.shadowing).length; let registered = 0; for (const entry of resolved) { From 951e03e665a8a4ba842d6e345df8151776516b1d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 14:06:19 +0000 Subject: [PATCH 2/3] 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 --- .../src/flow-name-shadowing.test.ts | 42 ++- .../src/flow-precedence-loader-set.test.ts | 240 ++++++++++++++++++ .../service-automation/src/flow-precedence.ts | 17 +- 3 files changed, 289 insertions(+), 10 deletions(-) create mode 100644 packages/services/service-automation/src/flow-precedence-loader-set.test.ts diff --git a/packages/services/service-automation/src/flow-name-shadowing.test.ts b/packages/services/service-automation/src/flow-name-shadowing.test.ts index dac0ac95704..c8216ecc64b 100644 --- a/packages/services/service-automation/src/flow-name-shadowing.test.ts +++ b/packages/services/service-automation/src/flow-name-shadowing.test.ts @@ -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) => (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(); @@ -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); @@ -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'); @@ -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', }); @@ -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'); }); }); diff --git a/packages/services/service-automation/src/flow-precedence-loader-set.test.ts b/packages/services/service-automation/src/flow-precedence-loader-set.test.ts new file mode 100644 index 00000000000..7e48f15f276 --- /dev/null +++ b/packages/services/service-automation/src/flow-precedence-loader-set.test.ts @@ -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 = {}) { + 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) => (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('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(); + } + }); +}); diff --git a/packages/services/service-automation/src/flow-precedence.ts b/packages/services/service-automation/src/flow-precedence.ts index 2d37e5a44ff..a0495f24303 100644 --- a/packages/services/service-automation/src/flow-precedence.ts +++ b/packages/services/service-automation/src/flow-precedence.ts @@ -216,6 +216,13 @@ function precedenceRank(contender: FlowContender): number { * boot order does. That case is warned about too — it is exactly as invisible as * the artifact-vs-DB one. * + * Two `runtime` contenders tie on both rules and keep arrival order. The boot + * hydration registers every stored row under the one bare-name slot, so the + * stored rows bring at most one tenant row per name into this list. Without a + * reader nothing is packaged, so two packages shipping one bare name tie the + * same way — the fail-closed composition gives up rule 2 rather than rank by + * a body's own claim. + * * [#20864] Which contender is `package` is the loader's set's answer, asked * ONCE per contested name so every contender of that name is judged against * one answer — see {@link describeFlowContender}. A name with one contender @@ -262,8 +269,14 @@ export function resolveFlowPrecedence( const ranked = [...group].sort((a, b) => { const byRank = precedenceRank(a.contender) - precedenceRank(b.contender); if (byRank !== 0) return byRank; - const byPackage = (a.contender.packageId ?? '').localeCompare(b.contender.packageId ?? ''); - if (byPackage !== 0) return byPackage; + // [#20864] Rule 2 is WITHIN `package` only, as stated above. A + // `runtime` contender's id is the body's own bytes, kept for + // display — letting it order tenant rows would hand the armed + // slot back to the stamps the loader's set just stopped trusting. + if (a.contender.source === 'package') { + const byPackage = (a.contender.packageId ?? '').localeCompare(b.contender.packageId ?? ''); + if (byPackage !== 0) return byPackage; + } // Fully-tied bodies: keep arrival order so the result is still total. return a.index - b.index; }); From 6a028afb2bededfcb9ff96259e9ce0e832beb41d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 14:13:27 +0000 Subject: [PATCH 3/3] chore(changeset): service-automation minor for the precedence loader's-set reader Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/20864-precedence-loader-set.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/20864-precedence-loader-set.md diff --git a/.changeset/20864-precedence-loader-set.md b/.changeset/20864-precedence-loader-set.md new file mode 100644 index 00000000000..8ca45b62860 --- /dev/null +++ b/.changeset/20864-precedence-loader-set.md @@ -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.