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
20 changes: 20 additions & 0 deletions .changeset/31672-flow-package-scoped-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/service-automation': patch
---

feat(service-automation)!: the flow index and the activation ledger consult on (package, name), through the one package-scoped resolver (ADR-0150 D3/D4)

Clause-②: no (narrowing) — a `subflow` or `map` node inside a packaged flow names its child by a bare name, and that name now resolves from the package that ships the flow (ADR-0150 D3): its own flow, else the one holder in its declared dependency closure (plus `sys`), else a refusal naming every candidate. Three cases that run today are refused: a name only an environment-authored flow holds (D3 (a)); a name only a package outside the caller's declared closure holds (D3 step 3); and a flow name two packages hold, which only a deployment sealed with a clash that predates phase 1 reaches (#31677 Q1, ruled A). No door, request or response shape, error code or status changes. One answer moves the other way, in a sealed clash only: an activation row of package Q, which still holds the flow name beside package P, no longer switches off P's same-named flow (below).
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export of the package root is removed or retyped; one type (FlowNameResolver) and one engine method (setFlowNameResolver) are added. The refusal is a run result naming the candidates, with no stored shape for a migration to rewrite and no tombstone. -->

**BREAKING** for a packaged flow whose `subflow` or `map` node names a flow its package cannot reach: one authored in the environment, or one shipped by a package it does not declare a dependency on. Measured first-party: 0 (every `subflow` / `map` `flowName` in this repository names a flow of its own package).

- **Before.** The engine looked the child up by its bare name alone, whoever held it.
- **Now.** The child resolves from the calling flow's package through `resolvePackageScopedName` (`@objectstack/objectql`). A refused call fails the node with the resolver's sentence, which names every holder and the remedy; the child never runs.
- **Unchanged.** A child the calling package holds, or that one package in its declared closure holds, runs as before. A door, a trigger or any caller outside a run resolves environment-wide, so every bare name with one holder answers as before. A flow the registry knows nothing of (registered straight into the engine) has one holder and runs as before. An engine with no ObjectQL registry answers as before.
- **Also.** The activation ledger's projection is keyed by `(package, name)`. The row of the package that ships the name decides for it, in either state. A row of another package that still holds a flow of that name keys only that package's flow, so it no longer switches the shipping package's flow off; only a deployment sealed with a pre-phase-1 clash has two such holders. A stale row, one whose package no longer holds the name, still switches the name off as before, and the engine now warns once naming both packages and the remedy: switch the flow again, which writes the shipping package's own row. A row that names no package, or any row when the engine cannot tell which package ships the name, switches the name off as before.
- **Also, a name two packages hold.** It is refused at registration, naming both, instead of arming the lexicographically first package's flow, and a run of it is refused the same way. Phase 1 refuses a second holder at install, so only a deployment that held the clash before phase 1 reaches this: its cold boot still seals with a warning (`BARE_NAME_COLD_BOOT_MODE = 'warn'`), and now neither flow is armed; the boot log names both. Measured first-party: 0 (across every boot of the dogfood battery, the cold boot's bare-name warning names one flow, the QA fixture's `qa_shared_flow`). The remedy is the warning's: rename one of the two flows.

**The remedy.** Declare a dependency on the package that ships the child (ADR-0141), or ship the child in the calling package. For an environment-authored child, move it into a package the caller depends on.
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@
// `AppPlugin` packages, `alpha` and `beta`, shipping one flow, one webhook,
// one connector and one email template under the same names — boots, and
// logs ONE bare-name warning line naming all four, both packages and the
// rename for every name; the automation engine's own flow precedence
// keeps arming one of the two flows, as before; the declared webhook's
// rename for every name; the automation engine arms NEITHER of the two
// flows and logs the refusal naming both, and the declared webhook's
// by-name read refuses the held name naming both packages (ADR-0150 D3,
// #31677: a clash held from before phase 1 is refused, not first-picked);
// #31677 Q1 ruled A: a clash held from before phase 1 is refused like any
// other two-holder name, not first-picked);
// - `POST /marketplace/install-local` of a third package declaring the held
// flow name is 422 PLUGIN_REGISTER_FAILED naming both holders and the
// rename, installs nothing, and the engine keeps serving the armed flow;
// rename, installs nothing, and the engine still arms neither flow;
// - the same door refusing a package that declares the held webhook name and
// the showcase's own flow and email template names, naming the showcase;
// - CONTROL: a package declaring fresh names installs — and so does one
Expand Down Expand Up @@ -224,16 +225,18 @@ describe('one package holder per flow, webhook, connector and email template nam
expect(line).not.toContain(BUILT_IN_TPL);
expect(line).not.toContain(baseline!);
expect(await packageIds(), 'both packages booted').toEqual(expect.arrayContaining(['com.qa.bare.alpha', 'com.qa.bare.beta']));
// Unchanged by the rule: the automation engine's precedence still arms one flow, naming both.
expect(bootLines.some((l) => l.includes(`Flow name collision: '${FLOW}'`) && l.includes('com.qa.bare.alpha') && l.includes('com.qa.bare.beta'))).toBe(true);
expect(await armedFlowLabel(FLOW)).toBe('alpha flow');
// [ADR-0150 D3, #31677 Q1] The automation engine arms neither flow: the arming is refused, naming both.
expect(bootLines.some((l) =>
l.includes('failed to register flow') && l.includes(`"${FLOW}"`) && l.includes('held by 2 packages')
&& l.includes('com.qa.bare.alpha') && l.includes('com.qa.bare.beta'))).toBe(true);
expect(await armedFlowLabel(FLOW)).toBeUndefined();
// ADR-0150 D3 (#31677, Q1 ruled A): the webhook the clash held before phase 1
// is no longer served by first registration — the by-name read refuses it,
// naming both packages.
await expectHeldWebhookRefused();
});

it('install-local of a third package declaring a held flow name is 422 PLUGIN_REGISTER_FAILED naming both holders and the rename; nothing is installed, the armed flow is untouched', async () => {
it('install-local of a third package declaring a held flow name is 422 PLUGIN_REGISTER_FAILED naming both holders and the rename; nothing is installed, and neither flow is armed', async () => {
const before = await packageIds();
const { status, body } = await installLocal(inline('gamma', { flows: [flow('gamma')] }));
expect([status, codeOf(body)], JSON.stringify(body)).toEqual([422, 'PLUGIN_REGISTER_FAILED']);
Expand All @@ -243,7 +246,7 @@ describe('one package holder per flow, webhook, connector and email template nam
expect(message).toContain('com.qa.bare.beta');
expect(message).toContain(`"gamma_${FLOW}"`);
expect(await packageIds(), 'the package list is unchanged').toEqual(before);
expect(await armedFlowLabel(FLOW)).toBe('alpha flow');
expect(await armedFlowLabel(FLOW)).toBeUndefined();
});

it('install-local of a package declaring the held webhook name and the showcase\'s own flow and email template names is refused, naming the showcase', async () => {
Expand Down
112 changes: 111 additions & 1 deletion packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ import {
refuseCustomerAuthoredToggle,
} from './subflow-guards.js';
import { describeSubflowNoticeTrap, subflowNoticeTrap } from './subflow-notice-guard.js';
// [ADR-0150 D3/D4] The one package-scoped resolver's answer, read through the
// reader the host attaches (`setFlowNameResolver`), and the activation
// projection keyed by `(package, name)`.
import type { PackageScopedRefusal, PackageScopedResolution } from '@objectstack/objectql';
import { FlowActivationProjection } from './flow-activation-projection.js';
import { describeRegionSuspendingCall, regionSuspendingCall } from './subflow-region-guard.js';
// [#25222] The consumed-suspension restore's bodies: journaling a consumed
// suspension, resolving its witnesses, and restoring, peeking at or inspecting
Expand Down Expand Up @@ -2567,6 +2572,17 @@ export interface FlowContender {
*/
export type PackagedFlowSource = (name: string) => string | undefined;

/**
* [ADR-0150 D3] A reader over the ONE package-scoped resolver: which item the
* bare flow name `name` means for a caller of package `callerPackageId`
* (`null` for a caller with no package). The automation plugin attaches
* `resolvePackageScopedName` from `@objectstack/objectql` over the ObjectQL
* registry ({@link AutomationEngine.setFlowNameResolver}); `undefined` means
* the host serves no registry to ask, and the engine's own flow map answers as
* before. ⛔ Never a second implementation of the rule.
*/
export type FlowNameResolver = (name: string, callerPackageId: string | null) => PackageScopedResolution | undefined;

/**
* [#28405] The stored body of flow `name` whose content identity is
* `checksum` ({@link SuspendedRun.flowChecksum}), as the metadata history
Expand Down Expand Up @@ -2759,7 +2775,26 @@ export class AutomationEngine implements IAutomationService {
* boot read is required anyway, `execute()` consults its result rather
* than issuing a datasource read on every single flow run.
*/
private flowLedgerDisabled = new Set<string>();
// [ADR-0150 D4] Keyed by `(package, name)`: a row switches off the flow of
// the package it names, read against the package that ships the name
// today — see {@link FlowActivationProjection}.
private flowLedgerDisabled = new FlowActivationProjection({
holderOf: (name) => this.packagedFlowOwner(name),
// A package still holds `name` when the resolver reaches that
// package's OWN flow for it; no resolver, no answer: stale.
stillHolds: (name, packageId) => {
const resolution = this.flowNameResolver?.(name, packageId);
return resolution?.status === 'resolved' && resolution.via === 'own';
},
reportStale: ({ name, rowPackageId, holderPackageId }) =>
this.logger.warn(
`[Automation] The flow "${name}" is switched off by an activation row of package "${rowPackageId}", `
+ `which no longer holds that name; package "${holderPackageId}" ships it today. The row still `
+ 'switches the flow off. To keep it off or turn it on, switch the flow again: that writes '
+ `package "${holderPackageId}"'s own row.`,
{ flow: name, rowPackageId, holderPackageId },
),
});
/**
* The durable ledger behind {@link flowLedgerDisabled}. `null` until a host
* attaches one via {@link setFlowActivationStore} — an engine with no
Expand All @@ -2774,6 +2809,13 @@ export class AutomationEngine implements IAutomationService {
* no flow a managed package loaded.
*/
private packagedFlowSource?: PackagedFlowSource;
/**
* [ADR-0150 D3] The reader that answers which item a bare flow name means
* for a caller — see {@link setFlowNameResolver}. `undefined` until a host
* attaches one; with none, the engine's own flow map is the only holder it
* knows, as before.
*/
private flowNameResolver?: FlowNameResolver;
/** [#20790] The write-only flow credential channel — see {@link setFlowCredentialSource}. */
private flowCredentialSource?: FlowCredentialSource;
/** [#20281 stage ③] The connector sync executor — see {@link setConnectorPullSource}. */
Expand Down Expand Up @@ -4531,6 +4573,13 @@ export class AutomationEngine implements IAutomationService {
if (refusal) throw new Error(refusal.message);
}

// [ADR-0150 D3/D4, #31677 Q1] The engine arms one flow per bare name, so
// a name the resolver cannot answer for a caller with no package — two
// packages hold it — is refused here, naming every holder, before
// anything is armed. ⛔ Never an arming by package id or by arrival.
const nameRefusal = this.flowNameRefusal(name, null);
if (nameRefusal) throw new Error(nameRefusal.message);

// Version history management
const history = this.flowVersionHistory.get(name) ?? [];
history.push({
Expand Down Expand Up @@ -4926,6 +4975,60 @@ export class AutomationEngine implements IAutomationService {
return this.packagedFlowOwner(name) !== undefined;
}

/**
* [ADR-0150 D3] Attach the reader over the one package-scoped resolver
* ({@link FlowNameResolver}). The automation plugin calls this at `init()`
* with a reader that asks the ObjectQL registry at question time, never at
* boot, for {@link setPackagedFlowSource}'s reason.
*
* Two seams read it, and only these two:
*
* - {@link registerFlow} asks it for a caller with no package before it
* arms a name, so a name two packages hold is refused, naming both,
* instead of arming one of them — a deployment sealed at its cold boot
* with a clash that predates phase 1 included (#31677 Q1, ruled A).
* The engine keeps one armed slot per bare name, and nothing picks
* between two holders.
* - {@link execute} asks it for the caller's package: the package that
* ships the flow whose run names the child (a `subflow` or `map` node's
* `flowName`, carried on the run's context as `flowName`), or no
* package for a door, a trigger or any caller outside a run. A refusal
* is the answer, naming every candidate; nothing is executed.
*
* A name the registry holds nothing under (a flow registered straight into
* the engine) has one holder, the engine's own flow map, and resolves to
* it as before.
*/
setFlowNameResolver(resolver: FlowNameResolver | undefined): void {
this.flowNameResolver = resolver;
}

/**
* [ADR-0150 D3] The resolver's refusal of the bare flow name `name` for a
* caller of package `callerPackageId` (`null`: no package), or `undefined`
* when the name resolves, names nothing in the registry, or no resolver is
* attached. Read-only; records nothing.
*/
private flowNameRefusal(name: string, callerPackageId: string | null): PackageScopedRefusal | undefined {
const resolution = this.flowNameResolver?.(name, callerPackageId);
return resolution?.status === 'refused' ? resolution : undefined;
}

/**
* [ADR-0150 D3] The package a call of {@link execute} comes from: the
* package that ships the flow whose run made the call — `context.flowName`,
* which the engine stamps on every run's context
* (`resolveRunContext`), so a `subflow` or `map` child carries its parent's
* name — or `null` for a caller outside a run, and for a run of a flow no
* package ships (an environment-authored flow resolves as a caller with no
* package, ADR-0150 D3).
*/
private flowCallerPackage(context: AutomationContext | undefined): string | null {
const caller = context?.flowName;
if (typeof caller !== 'string' || caller === '') return null;
return this.packagedFlowOwner(caller) ?? null;
}

/**
* [#20790] Attach the write-only flow credential channel. The automation
* plugin calls this at `init()`, before any flow is registered. With none
Expand Down Expand Up @@ -6130,6 +6233,13 @@ export class AutomationEngine implements IAutomationService {

async execute(flowName: string, context?: AutomationContext): Promise<AutomationResult> {
const startTime = Date.now();
// [ADR-0150 D3] The bare name resolves for the caller's package first:
// a run of package P's flow reaches P's own flow, else the one holder
// in P's declared dependency closure; anything else is refused with
// every candidate named, and nothing runs. Never dispatched, so no
// `status`, for the reason the disabled exit below states.
const nameRefusal = this.flowNameRefusal(flowName, this.flowCallerPackage(context));
if (nameRefusal) return { success: false, error: nameRefusal.message };
const flow = this.flows.get(flowName);

if (!flow) {
Expand Down
Loading
Loading