Skip to content
Merged
14 changes: 14 additions & 0 deletions .changeset/31677-connector-action-package-scoped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/service-automation': patch
---

feat: a `connector_action` names its connector from the calling flow's package (ADR-0150 D3/D4)

Clause-②: no
Metadata-protocol: none

A flow's `connectorConfig.connectorId` is now resolved through `resolvePackageScopedName` from `@objectstack/objectql`, as the package that ships the flow: its own declared connector instance, else the one holder in its declared dependency closure, else a refusal naming every candidate. An environment-authored flow names it with no package, as before.

Who is affected: a packaged flow whose `connector_action` names a declared connector instance its package can't reach: one held only by a package outside its declared dependencies, one only the environment authored (ADR-0150 D3 (a)), or one two packages in its closure both declare. That node was dispatched to whatever instance the engine held under the name; it is now refused, and the error names every candidate. Declare the dependency, or ship the instance in the flow's own package. First-party count: 0. Every first-party packaged flow names its own package's instance or a connector a plugin registers in code, which is no registry item and dispatches as before.

Corrected beside it: which package a flow run names its references from. It is the package only while that package's OWN body is armed under the flow's name (`AutomationEngine.armedFlowPackage`; the engine records where each armed body came from at every `registerFlow`). An environment override of a packaged flow, whether a package-less stored row or the shipping package's declared `.custom` override, names its `connector_action` connector and its `subflow`/`map` flow with no package, as ADR-0150 D3 says an overlay's references do. Before this, E1's subflow reader read the package that ships the flow name, so an override's subflow name resolved from that package; it now resolves as it did on main before E1. That is a loosening back to the earlier answer, not a new narrowing.
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont
);
}

// [ADR-0150 D3, epic #31527 child E5] A packaged flow names its
// connector from its package: refused (every candidate named) when
// the name does not resolve for that package — never dispatched on
// a guess.
const nameRefusal = engine.connectorNameRefusal(cfg.connectorId, context);
if (nameRefusal) {
return refuseNode(`connector_action '${node.id}': ${nameRefusal.message}`);
}

const handler = engine.resolveConnectorAction(cfg.connectorId, cfg.actionId);
if (!handler) {
// A degraded declarative instance (#3017) is registered but has no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
resolvePackageScopedName,
type PackageScopedRefusal,
type PackageScopedRegistry,
type PackageScopedResolution,
} from '@objectstack/objectql';

/** The registry, when it carries the slice the package-scoped resolver reads. */
Expand Down Expand Up @@ -59,3 +60,29 @@ export function refusedConnectorNames(
}
return refused;
}

/**
* [ADR-0150 D3] The engine's reader over the one package-scoped resolver for
* the `connector` type (`AutomationEngine.setConnectorNameResolver`): which
* declared connector instance a bare `connectorConfig.connectorId` means for a
* caller of package `callerPackageId` (`null`: no package).
*
* Resolved at QUESTION time, never at boot, like the flow reader. A
* composition with no ObjectQL, or whose registry offers none of the
* package-scoped reads, has no registry to ask: the answer is `undefined` and
* `connector_action` dispatches by name as before.
*/
export function connectorNameResolver(
ctx: { getService<T>(name: string): T },
): (name: string, callerPackageId: string | null) => PackageScopedResolution | undefined {
return (name, callerPackageId) => {
let registry: PackageScopedRegistry | undefined;
try {
registry = packageScopedRegistryOf(ctx.getService<{ registry?: unknown }>('objectql')?.registry);
} catch {
return undefined;
}
if (!registry) return undefined;
return resolvePackageScopedName(registry, { type: 'connector', name, callerPackageId });
};
}
113 changes: 108 additions & 5 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2583,6 +2583,17 @@ export type PackagedFlowSource = (name: string) => string | undefined;
*/
export type FlowNameResolver = (name: string, callerPackageId: string | null) => PackageScopedResolution | undefined;

/**
* [ADR-0150 D3, epic #31527 child E5] The same reader for the `connector` type:
* which declared connector instance the bare name `name` means for a caller of
* package `callerPackageId` (`null` for a caller with no package). The
* automation plugin attaches `resolvePackageScopedName` over the ObjectQL
* registry ({@link AutomationEngine.setConnectorNameResolver}); `undefined`
* means the host serves no registry to ask. ⛔ Never a second implementation
* of the rule.
*/
export type ConnectorNameResolver = (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 @@ -2823,6 +2834,19 @@ export class AutomationEngine implements IAutomationService {
* knows, as before.
*/
private flowNameResolver?: FlowNameResolver;
/**
* [ADR-0150 D3, epic #31527 child E5] Where each armed body came from, keyed
* by the parsed body {@link registerFlow} stores — so {@link armedFlowPackage}
* reads the arm of exactly what `flows` holds under a name, and a withdrawn
* or replaced body takes its arm with it. Written at EVERY registration:
* the caller's classification when it hands one, else `runtime`.
*/
private readonly flowArms = new WeakMap<object, FlowContender>();
/**
* [ADR-0150 D3] The reader that answers which declared connector instance a
* bare name means for a caller — see {@link setConnectorNameResolver}.
*/
private connectorNameResolver?: ConnectorNameResolver;
/** [#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 @@ -4556,7 +4580,21 @@ export class AutomationEngine implements IAutomationService {
return canonicalizeFlow.call(host, name, definition, options);
}

registerFlow(name: string, definition: unknown): FlowParsed {
/**
* @param armedFrom [ADR-0150 D3, epic #31527 child E5] Where `definition`
* comes from: the automation plugin's arming steps hand the precedence
* winner's classification (`resolveFlowPrecedence`) — `package` for the
* loader's own body, `override` for the shipping package's declared
* `.custom` override, `runtime` for an environment-authored row. Absent,
* the body is the caller's own — a write door's request body — and arms
* as `runtime`, unless it IS the parsed body this engine armed (a door
* putting back the definition it held), which keeps the arm it had.
* {@link armedFlowPackage} reads it.
*/
registerFlow(name: string, definition: unknown, armedFrom?: FlowContender): FlowParsed {
const arm: FlowContender = armedFrom
?? (definition !== null && typeof definition === 'object' ? this.flowArms.get(definition) : undefined)
?? { source: 'runtime' };
// One canonicalization policy, shared with the stored-row migration so
// the two can never disagree about what "canonical" means (#4454).
// Execution takes the parsed shape (schema defaults materialized).
Expand Down Expand Up @@ -4601,6 +4639,7 @@ export class AutomationEngine implements IAutomationService {
// history keys this version by. See {@link SuspendedRun.flowChecksum}.
const checksum = flowContentChecksum(definition);
if (checksum !== undefined) this.flowChecksums.set(parsed, checksum);
this.flowArms.set(parsed, arm);
this.flows.set(name, parsed);
// [#23316] Held now, so no longer refused — see {@link flowRefusals}.
this.flowRefusals.delete(name);
Expand Down Expand Up @@ -5021,19 +5060,83 @@ export class AutomationEngine implements IAutomationService {
return resolution?.status === 'refused' ? resolution : undefined;
}

/**
* [ADR-0150 D3, epic #31527 child E5] Attach the reader that answers which
* declared connector instance a bare `connectorConfig.connectorId` means for
* the flow that names it ({@link connectorNameRefusal}). The automation
* plugin calls this at `init()`; with none attached, `connector_action`
* dispatches by name as before.
*/
setConnectorNameResolver(resolver: ConnectorNameResolver | undefined): void {
this.connectorNameResolver = resolver;
}

/**
* [ADR-0150 D3, epic #31527 child E5] Why a `connector_action` in a run of
* `context`'s flow may not dispatch to the connector instance named
* `connectorId`, or `undefined` when it may.
*
* The name is resolved for the package that ships the calling flow
* ({@link flowCallerPackage}: a packaged flow names it from its package; an
* environment-authored flow, or a call outside a run, names it with no
* package). The engine holds ONE instance per name, materialized for the
* answer a caller with no package gets (the declared-connector reconcile,
* which already refuses a name two packages hold). So a package caller
* whose answer is a refusal — two holders in its closure, or holders only
* outside it, an environment-only instance included (ADR-0150 D3 (a)) — is
* refused with the resolver's own message, naming every candidate. Any
* other answer is the instance the engine serves: an environment item
* exists only for a name no package holds, so a package caller that
* resolves at all resolves to the one package holder the reconcile
* materialized. A name the registry holds nothing under (a connector a
* plugin registers in code), or a host with no resolver, dispatches by name
* as before. Read-only.
*/
connectorNameRefusal(connectorId: string, context: AutomationContext | undefined): PackageScopedRefusal | undefined {
const callerPackageId = this.flowCallerPackage(context);
if (callerPackageId === null) return undefined;
const meant = this.connectorNameResolver?.(connectorId, callerPackageId);
return meant?.status === 'refused' ? meant : 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).
* name — or `null` for a caller outside a run, and for a run whose armed
* body is not the package's own ({@link armedFlowPackage}): an
* environment-authored flow, or an environment override of a packaged
* 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;
return this.armedFlowPackage(caller) ?? null;
}

/**
* [ADR-0150 D3, epic #31527 child E5] The package whose OWN body is armed
* under the flow name `name`: the loader's package
* ({@link packagedFlowOwner}) when the registration the engine holds was
* armed from the loader's body (`package`, {@link registerFlow}'s
* `armedFrom`), else `undefined` — for the shipping package's declared
* `.custom` override, for an environment-authored (`runtime`) body, and
* for a name the engine does not hold.
*
* This is the package a reference written in the armed body resolves
* from. ADR-0150 D3: "A reference written in an environment-authored
* overlay (ADR-0005) is the environment's reference. It resolves as a
* caller with no package … It never resolves from the package the overlay
* customizes." So the loader's set alone (which answers by bare name, for
* whichever body is armed) is never this answer, and neither is a stamp
* on the body: the boot hydration and the protocol's served override
* both carry the base's `_packageId`. Read-only.
*/
armedFlowPackage(name: string): string | undefined {
const armed = this.flows.get(name);
if (!armed || this.flowArms.get(armed)?.source !== 'package') return undefined;
return this.packagedFlowOwner(name);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,46 @@ describe('[#28717] a declared .custom flow override is armed from the first boot
});
});

/**
* [ADR-0150 D3, epic #31527 child E5] Which package a reference written in the
* armed body resolves from (`AutomationEngine.armedFlowPackage`, the caller
* package of a `subflow` / `map` name and of a `connector_action`'s
* `connectorId`). The loader's package only while the loader's OWN body is
* armed; the shipping package's declared `.custom` override is an
* environment-authored overlay, whose references resolve with no package
* (ADR-0150 D3: "It never resolves from the package the overlay customizes").
*
* Read on the real composition at both arming steps: the boot pull arms from
* the registry's list (the override as a stored row, with its binding), and
* `kernel:ready` re-arms from the protocol's view (the override served in the
* base's slot, stamped with the base's `_packageId`), so a stamp-reading
* answer would read the base at one step or the other.
*/
describe('[ADR-0150 D3, #31677] the armed body\'s package is the loader\'s only while the loader\'s own body is armed', () => {
const CASES: ReadonlyArray<readonly [string, readonly Row[], string | undefined]> = [
['the loader\'s body alone', [], BASE],
['a package-less stored row beside it (shadowed, the loader\'s body armed)', ['legacy'], BASE],
['the declared .custom override', ['override'], undefined],
['the declared override and a package-less row', ['override', 'legacy'], undefined],
];
for (const [shape, rows, expected] of CASES) {
it(`${shape}: ${expected ?? 'no package'} at the boot pull and after kernel:ready`, async () => {
let atPull: { label?: string; pkg?: string } = {};
const { automation } = await measuredBoot(rows, async (_ql, engine) => {
atPull = {
label: ((await engine.getFlow(FLOW)) as { label?: string } | null)?.label,
pkg: engine.armedFlowPackage(FLOW),
};
});
const armedLabel = expected === undefined ? 'OVERRIDE' : 'BASE';
expect(automation.packagedFlowOwner(FLOW), 'control: the loader\'s set holds the name').toBe(BASE);
expect(atPull).toEqual({ label: armedLabel, pkg: expected });
expect(((await automation.getFlow(FLOW)) as { label?: string } | null)?.label).toBe(armedLabel);
expect(automation.armedFlowPackage(FLOW)).toBe(expected);
});
}
});

/**
* [#31754, ADR-0150 D3] The collision line agrees with the engine on a real
* cold boot. A second package shipping FLOW's bare name (a clash sealed before
Expand Down
Loading
Loading