Skip to content

Commit 55c4e95

Browse files
feat(service-automation)!: the flow index and the activation ledger consult on (package, name) (ADR-0150 E1) (#31738)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9U3goQBvwMsJHNB3R7mZR7M)_ Fixes #31672 Clause-②: no (narrowing) Metadata-protocol: none Narrowing: (1) D3 (a), as the card declares: a package flow's bare `subflow` / `map` reference whose only holder is an environment-authored flow is refused (`outside-closure`, naming the environment). (2) D3 step 3, ruled inside ADR-0150 by the epic seat (#31527 comment 6728487836): a package flow's bare reference whose only holder is a package outside its declared dependency closure is refused (`outside-closure`, naming that package). Measured first-party for (1) and (2): 0, including the sibling-in-one-artifact sub-case (ADR-0130 co-ownership): 0. (3) A flow name two packages hold, which only a deployment sealed at its cold boot with a clash that predates phase 1 reaches (#31677 Q1, ruled A, comment 6729831159): arming it is refused and a run of it is refused, naming both, so neither flow is armed (it used to arm the lexicographically first package's flow). Measured first-party: 0. Methods under Verification. Skills-docs: follow-up #31741 epic:#31527 child E1 · `domain:server#3` · dev session `session_01ADHkmQjmHnuYAuaRd9k252` ## 维护者速读 - 改了什么:流程引擎按(软件包, 名称)解析裸流程名,全部经 R 的共享解析器 `resolvePackageScopedName`。软件包内流程的子流程/映射节点先找本包,再找它声明依赖闭包里唯一的持有者,否则拒绝并列出全部候选。两个软件包同名的流程(包括冷启动封存的 phase 1 之前的旧冲突)在注册和运行时都拒绝,不再按包名字典序挑一个。启停台账按(软件包, 名称)生效:过期行仍然关闭当前持有者,并警告一次。 - 为什么改:专题 #31527 子卡 E1(ADR-0150 D3/D4),以及 #31677 Q1 裁决 A。 - 风险与回滚:收窄三处,第一方实测都是 0 处。单持有者的裸名、REST 门、触发器行为不变。phase 1 的 2 条 dogfood 用例按裁决改为读拒绝。回滚即还原本 PR。 - 先看:`engine.ts` 的 `registerFlow` 与 `execute` 两处 `flowNameRefusal` 闸门;`plugin.ts` 的 `flowNameResolver`;`flow-activation-projection.ts`。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run src/flow-package-scoped-resolution.test.ts`,预期 20 条全绿。 - 需要你做的:无。席位在史诗席复核通过且 CI 绿后落地。 ## What changes **Step 0 — every read point that looks a flow up by bare name today** (on `origin/main` b126c13): | # | Read point | File / symbol | Caller | This PR | |:--|:--|:--|:--|:--| | 1 | arming a name (boot pull, `kernel:ready` bind, reload re-sync, mutation sync all end here) | `engine.ts` `registerFlow` (via `plugin.ts` `resolveFlowContenders` → `flow-precedence.ts` `resolveFlowPrecedence`, which arms the lexicographically-first package on two package holders) | no package | gated: the resolver is asked for a caller with no package; a name two packages hold is refused, naming both, and nothing is armed | | 2 | running a flow by name | `engine.ts` `execute` (`this.flows.get(flowName)`) | doors, triggers (no package); `subflow` / `map` children (the calling flow's package) | gated: the resolver is asked for the caller's package | | 3 | `subflow` / `map` child name | `builtin/subflow-node.ts`, `builtin/map-node.ts` → `engine.execute(flowName, childContext)` | the parent flow's package (`childContext.flowName`, stamped by `resolveRunContext`) | resolved through #2 | | 4 | activation ledger projection | `flow-definitions.ts` `hydrateFlowActivations`, `toggleFlow`; read by `isFlowEnabled`, `describeDisabledFlow`, the §7.3 subflow guards (`subflow-guards.ts`) | — | keyed by `(package, name)` (`FlowActivationProjection`) | | 5 | which package ships a name | `engine.ts` `packagedFlowOwner` (the loader's set, `plugin.ts` `packagedFlowReader`) | — | unchanged; it is now also the caller package of #2 and the holder #4 reads | | 6 | context-free by-name reads | `getFlow`, `getFlowRegistration`, `toggleFlow`, `getFlowVersionHistory`, `rollbackFlow`, `unregisterFlow`, `flowCredentialHoldings` | no package | unchanged: they read the engine's one armed slot per name, which #1 now fills only for a name with one holder | | 7 | stored references by flow name | run store (`flowName`), credential channel, dispatch keys, approvals `flow:NAME`, history by checksum | — | not here: S3 #31684 (R7–R10, R14, R20) adds the package columns | | 8 | screen node `getFlow(flowName)` | `builtin/screen-nodes.ts` | the run's own flow | unchanged: reads the running flow's own definition | **Step 1.** Both gates call one engine seam, `flowNameRefusal`, which asks the reader the host attaches (`setFlowNameResolver`, new). The automation plugin attaches `flowNameResolver(ctx)`: `resolvePackageScopedName` from `@objectstack/objectql` over the ObjectQL registry, read at question time (the startup-registry rule). ⛔ No second resolver and no first-registered fallback: the engine reads only the resolver's `refused` answer; `resolved` and `not-found` leave the existing path (a name the registry knows nothing of has one holder, the engine's own map). - **Caller package.** `execute` reads `context.flowName`, which the engine stamps on every run context (`resolveRunContext`), so a child call carries its parent's name; the caller package is that flow's `packagedFlowOwner`, or none for a door, a trigger, or an environment-authored flow (ADR-0150 D3: an environment item resolves as a caller with no package). - **Arming.** `registerFlow` asks for a caller with no package, so a name two packages hold (`ambiguous-in-environment`) throws the resolver's sentence before anything is armed. Each of the four call sites already catches a registration throw and logs it as a warning carrying the flow and the sentence naming both packages (the boot pull's is `[Automation] failed to register flow`). - **Activation.** `flowLedgerDisabled` becomes a `FlowActivationProjection` that keeps each row's package and state. The row of the package that ships the name decides for it, in either state. A row of another package that still holds the name keys only that package's flow (D4). A stale row, whose package no longer holds the name, still switches the shipping package's flow off, as before rows were keyed, and is warned once naming both packages and the remedy: switch the flow again, which writes the shipping package's own row (epic seat ruling B on Q3). "Still holds" is the resolver reaching that package's own flow; with no resolver it reads as stale, so the row keeps the flow off. A row naming no package binds to the one holder (D8's backfill reading). The two writers stay `hydrateFlowActivations` and `toggleFlow` (the existing pin in `flow-activation-ledger.test.ts` holds). - **Dependency.** `@objectstack/objectql` is a runtime dependency of service-automation. E5 made that move on `main` first, so after the merge it is no longer in this diff. No cycle: objectql's runtime workspace closure (11 packages) does not contain service-automation; `pnpm check:workspace-manifest-cycles` green. The flow reader asks for the registry slice through E5's `packageScopedRegistryOf`, so the slice is checked in one place. **ADR-0150's four readings.** - **(a)** Phase 1's one-holder tests stay green: `flow` stays in `BARE_NAME_ONE_HOLDER_TYPES` and this PR touches nothing in objectql. Of phase 1's dogfood pins, exactly the two that read the sealed clash's armed flow move to the refusal, as #31677 Q1 rules; every other pin is unchanged. - **(b)** Bare-name behaviour is unchanged for every name with one holder, except the declared narrowing: a door, a trigger, MCP and the SDK reach `execute` with no run context, so they resolve environment-wide, and a name with one holder resolves (pinned: `reading (b)` block, plus the door control in the red-line-1 block). - **(c)** No migration in this PR. - **(d)** `Clause-②: no (narrowing)`; no protocol change, so no ADR-0087 migration entry (`not-required (no-migration-prescription)` in the changeset). ## Tests New `src/flow-package-scoped-resolution.test.ts` (20 cases) over a real `SchemaRegistry` (packages through `installPackage`, so the declared closure is the save door's) and the plugin's own `flowNameResolver`: - own flow, closure holder, environment caller to a package holder, engine-only flow: all run; - red line 1: D3 (a) refused naming the environment; D3 step 3 refused naming the stranger package; the door control runs the same environment flow; - red line 2: a name two packages hold is refused at registration naming both, nothing armed; a door run of a sealed clash is refused naming both; a package caller whose closure holds the name twice is refused naming both; control: one holder arms; - reading (b): a door runs a packaged flow by its bare name; with no resolver attached a subflow reaches an environment flow as before; - D4: the shipping package's row switches its flow off, a row of another package that still holds the name does not; a stale row still switches the holder off, is warned once naming both packages and the remedy, and the remedy works; the toggle door writes and reads under the shipping package; four `FlowActivationProjection` unit cases. Forced test changes: `packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts`, the two pins that read the sealed clash's flow as armed (`armedFlowLabel('qa_shared_flow') === 'alpha flow'`, in the cold-boot case and the third-package install case) now read it unarmed and find the refusal naming both packages in the boot log. The seat directed this after #31677 Q1 was ruled A. The file's header now states the refusal. No other existing test changed. ## Acceptance notes - **D4's "all same-named flows arm" is not done here.** The engine keeps one armed slot per bare name and refuses a name two packages hold. Per the epic seat, arming both is a prerequisite of B #31696 (which narrows phase 1 for flows), not of S3: it becomes reachable only when phase 1 stops refusing a second flow holder. - **A missing resolver slice refuses nothing.** An engine with no ObjectQL registry answers as before; accepted while phase 1 refuses for `flow`. B #31696 must fail closed on a missing slice or prove every composition carries it (the epic seat's recorded obligation, same as E5's). - **The precedence's collision line still says "arming … by package id".** For two package holders `resolveFlowPrecedence` still logs `Flow name collision: … arming <first> by package id`, and the refusal line follows it naming both. The wording is pinned by `packages/cli/src/utils/format.boot-warning-classes.test.ts`, outside this card's file surface, so it is reported as a follow-up and not changed here. - The §7.3 subflow guards still list callers by bare reference, so a caller whose reference is now refused still counts as reaching the child (a stricter guard, unchanged from today). - A refused child call answers `success: false` with the resolver's sentence and no `code`: no new error code is minted. The doors' `409 RESOURCE_CONFLICT` for an ambiguous bare address is G1's (D5). E2 (actions) and E6 (api endpoints) callers execute with no caller package until their cards land (seat: A). ## Verification <details><summary>Ablations (each refusal and ledger pin; mutation through scripts/ablation-replace.mjs on a committed head, restored, blob == HEAD, git diff HEAD empty)</summary> - The `registerFlow` gate deleted (head c2f2031): `Tests 1 failed | 19 passed (20)`: the two-holder registration pin goes red. - The `execute` gate deleted (head c2f2031): `4 failed`: D3 (a), D3 step 3, the sealed-clash door run and the ambiguous-in-closure caller all go red (the child runs: this is also the base reading, the behaviour on `main`). - The projection's "still holds" check replaced by `continue` (head 4cf79d4): `Tests 2 failed | 15 passed (17)`: the stale-row engine pin and unit pin go red. - The stale-row report deleted (head 4cf79d4): `2 failed | 15 passed (17)`: the same two pins go red on the warning. - The holder's own row no longer deciding (head 4cf79d4): `3 failed | 14 passed (17)`: the foreign-holder row, stale-row remedy and toggle pins go red. </details> <details><summary>First-party narrowing measures</summary> - (1), (2): every `type: 'subflow'` / `type: 'map'` node in `examples/**` and `packages/**` outside tests and the spec (`git grep` on b126c13): 5 nodes, all in `examples/app-showcase`, naming `showcase_notify_owner`, `showcase_closure_signoff`, `showcase_one_task_signoff`, `showcase_recalc_task_estimate`, all flows of the same package. Refused by D3 (a): 0; by D3 step 3: 0; sibling-in-one-artifact sub-case: 0. - (3): across every boot of the full dogfood battery (bcc5ba6), the cold boot's `Bare-name conflict at boot` warning appears 4 times and names one flow, `qa_shared_flow`, the QA fixture that builds the clash on purpose. First-party: 0. </details> <details><summary>Gates, suite and dogfood (head d7a38bb, merged with main at c28d8e7)</summary> - **Derived gates:** `dispatch-gates.mjs --ran`: 76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN; every recorded exit is 0 on d7a38bb. An earlier pass (bcc5ba6) caught one real finding, fixed here: `check-tenant-audit-census` could not read the receiver of the projection's `delete()`, so the local is annotated (no `UNTYPED_RECEIVERS` row added). - **Package:** service-automation `vitest run` 267 files / 3478 tests passed; `tsc --noEmit` 0; eslint on the package and the dogfood file 0. - **Dogfood** (`turbo run build --filter='@objectstack/dogfood^...'` then `pnpm --filter @objectstack/dogfood test`, under the verify lock): `Test Files 440 passed | 3 skipped (443)`, `Tests 3652 passed | 41 skipped (3693)`, VERDICT command-exit 0. That includes the two moved phase-1 pins. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQjmHnuYAuaRd9k252)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmQjmHnuYAuaRd9k252 <!-- signed: domain:server#3 · os-steve · session_01ADHkmQjmHnuYAuaRd9k252 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 62cbf9a commit 55c4e95

8 files changed

Lines changed: 676 additions & 17 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
feat(service-automation)!: the flow index and the activation ledger consult on (package, name), through the one package-scoped resolver (ADR-0150 D3/D4)
6+
7+
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).
8+
Metadata-protocol: none
9+
10+
<!-- 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. -->
11+
12+
**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).
13+
14+
- **Before.** The engine looked the child up by its bare name alone, whoever held it.
15+
- **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.
16+
- **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.
17+
- **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.
18+
- **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.
19+
20+
**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.

‎packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515
// `AppPlugin` packages, `alpha` and `beta`, shipping one flow, one webhook,
1616
// one connector and one email template under the same names — boots, and
1717
// logs ONE bare-name warning line naming all four, both packages and the
18-
// rename for every name; the automation engine's own flow precedence
19-
// keeps arming one of the two flows, as before; the declared webhook's
18+
// rename for every name; the automation engine arms NEITHER of the two
19+
// flows and logs the refusal naming both, and the declared webhook's
2020
// by-name read refuses the held name naming both packages (ADR-0150 D3,
21-
// #31677: a clash held from before phase 1 is refused, not first-picked);
21+
// #31677 Q1 ruled A: a clash held from before phase 1 is refused like any
22+
// other two-holder name, not first-picked);
2223
// - `POST /marketplace/install-local` of a third package declaring the held
2324
// flow name is 422 PLUGIN_REGISTER_FAILED naming both holders and the
24-
// rename, installs nothing, and the engine keeps serving the armed flow;
25+
// rename, installs nothing, and the engine still arms neither flow;
2526
// - the same door refusing a package that declares the held webhook name and
2627
// the showcase's own flow and email template names, naming the showcase;
2728
// - CONTROL: a package declaring fresh names installs — and so does one
@@ -224,16 +225,18 @@ describe('one package holder per flow, webhook, connector and email template nam
224225
expect(line).not.toContain(BUILT_IN_TPL);
225226
expect(line).not.toContain(baseline!);
226227
expect(await packageIds(), 'both packages booted').toEqual(expect.arrayContaining(['com.qa.bare.alpha', 'com.qa.bare.beta']));
227-
// Unchanged by the rule: the automation engine's precedence still arms one flow, naming both.
228-
expect(bootLines.some((l) => l.includes(`Flow name collision: '${FLOW}'`) && l.includes('com.qa.bare.alpha') && l.includes('com.qa.bare.beta'))).toBe(true);
229-
expect(await armedFlowLabel(FLOW)).toBe('alpha flow');
228+
// [ADR-0150 D3, #31677 Q1] The automation engine arms neither flow: the arming is refused, naming both.
229+
expect(bootLines.some((l) =>
230+
l.includes('failed to register flow') && l.includes(`"${FLOW}"`) && l.includes('held by 2 packages')
231+
&& l.includes('com.qa.bare.alpha') && l.includes('com.qa.bare.beta'))).toBe(true);
232+
expect(await armedFlowLabel(FLOW)).toBeUndefined();
230233
// ADR-0150 D3 (#31677, Q1 ruled A): the webhook the clash held before phase 1
231234
// is no longer served by first registration — the by-name read refuses it,
232235
// naming both packages.
233236
await expectHeldWebhookRefused();
234237
});
235238

236-
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 () => {
239+
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 () => {
237240
const before = await packageIds();
238241
const { status, body } = await installLocal(inline('gamma', { flows: [flow('gamma')] }));
239242
expect([status, codeOf(body)], JSON.stringify(body)).toEqual([422, 'PLUGIN_REGISTER_FAILED']);
@@ -243,7 +246,7 @@ describe('one package holder per flow, webhook, connector and email template nam
243246
expect(message).toContain('com.qa.bare.beta');
244247
expect(message).toContain(`"gamma_${FLOW}"`);
245248
expect(await packageIds(), 'the package list is unchanged').toEqual(before);
246-
expect(await armedFlowLabel(FLOW)).toBe('alpha flow');
249+
expect(await armedFlowLabel(FLOW)).toBeUndefined();
247250
});
248251

249252
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 () => {

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ import {
224224
refuseCustomerAuthoredToggle,
225225
} from './subflow-guards.js';
226226
import { describeSubflowNoticeTrap, subflowNoticeTrap } from './subflow-notice-guard.js';
227+
// [ADR-0150 D3/D4] The one package-scoped resolver's answer, read through the
228+
// reader the host attaches (`setFlowNameResolver`), and the activation
229+
// projection keyed by `(package, name)`.
230+
import type { PackageScopedRefusal, PackageScopedResolution } from '@objectstack/objectql';
231+
import { FlowActivationProjection } from './flow-activation-projection.js';
227232
import { describeRegionSuspendingCall, regionSuspendingCall } from './subflow-region-guard.js';
228233
// [#25222] The consumed-suspension restore's bodies: journaling a consumed
229234
// suspension, resolving its witnesses, and restoring, peeking at or inspecting
@@ -2567,6 +2572,17 @@ export interface FlowContender {
25672572
*/
25682573
export type PackagedFlowSource = (name: string) => string | undefined;
25692574

2575+
/**
2576+
* [ADR-0150 D3] A reader over the ONE package-scoped resolver: which item the
2577+
* bare flow name `name` means for a caller of package `callerPackageId`
2578+
* (`null` for a caller with no package). The automation plugin attaches
2579+
* `resolvePackageScopedName` from `@objectstack/objectql` over the ObjectQL
2580+
* registry ({@link AutomationEngine.setFlowNameResolver}); `undefined` means
2581+
* the host serves no registry to ask, and the engine's own flow map answers as
2582+
* before. ⛔ Never a second implementation of the rule.
2583+
*/
2584+
export type FlowNameResolver = (name: string, callerPackageId: string | null) => PackageScopedResolution | undefined;
2585+
25702586
/**
25712587
* [#28405] The stored body of flow `name` whose content identity is
25722588
* `checksum` ({@link SuspendedRun.flowChecksum}), as the metadata history
@@ -2766,7 +2782,26 @@ export class AutomationEngine implements IAutomationService {
27662782
* boot read is required anyway, `execute()` consults its result rather
27672783
* than issuing a datasource read on every single flow run.
27682784
*/
2769-
private flowLedgerDisabled = new Set<string>();
2785+
// [ADR-0150 D4] Keyed by `(package, name)`: a row switches off the flow of
2786+
// the package it names, read against the package that ships the name
2787+
// today — see {@link FlowActivationProjection}.
2788+
private flowLedgerDisabled = new FlowActivationProjection({
2789+
holderOf: (name) => this.packagedFlowOwner(name),
2790+
// A package still holds `name` when the resolver reaches that
2791+
// package's OWN flow for it; no resolver, no answer: stale.
2792+
stillHolds: (name, packageId) => {
2793+
const resolution = this.flowNameResolver?.(name, packageId);
2794+
return resolution?.status === 'resolved' && resolution.via === 'own';
2795+
},
2796+
reportStale: ({ name, rowPackageId, holderPackageId }) =>
2797+
this.logger.warn(
2798+
`[Automation] The flow "${name}" is switched off by an activation row of package "${rowPackageId}", `
2799+
+ `which no longer holds that name; package "${holderPackageId}" ships it today. The row still `
2800+
+ 'switches the flow off. To keep it off or turn it on, switch the flow again: that writes '
2801+
+ `package "${holderPackageId}"'s own row.`,
2802+
{ flow: name, rowPackageId, holderPackageId },
2803+
),
2804+
});
27702805
/**
27712806
* The durable ledger behind {@link flowLedgerDisabled}. `null` until a host
27722807
* attaches one via {@link setFlowActivationStore} — an engine with no
@@ -2781,6 +2816,13 @@ export class AutomationEngine implements IAutomationService {
27812816
* no flow a managed package loaded.
27822817
*/
27832818
private packagedFlowSource?: PackagedFlowSource;
2819+
/**
2820+
* [ADR-0150 D3] The reader that answers which item a bare flow name means
2821+
* for a caller — see {@link setFlowNameResolver}. `undefined` until a host
2822+
* attaches one; with none, the engine's own flow map is the only holder it
2823+
* knows, as before.
2824+
*/
2825+
private flowNameResolver?: FlowNameResolver;
27842826
/** [#20790] The write-only flow credential channel — see {@link setFlowCredentialSource}. */
27852827
private flowCredentialSource?: FlowCredentialSource;
27862828
/** [#20281 stage ③] The connector sync executor — see {@link setConnectorPullSource}. */
@@ -4538,6 +4580,13 @@ export class AutomationEngine implements IAutomationService {
45384580
if (refusal) throw new Error(refusal.message);
45394581
}
45404582

4583+
// [ADR-0150 D3/D4, #31677 Q1] The engine arms one flow per bare name, so
4584+
// a name the resolver cannot answer for a caller with no package — two
4585+
// packages hold it — is refused here, naming every holder, before
4586+
// anything is armed. ⛔ Never an arming by package id or by arrival.
4587+
const nameRefusal = this.flowNameRefusal(name, null);
4588+
if (nameRefusal) throw new Error(nameRefusal.message);
4589+
45414590
// Version history management
45424591
const history = this.flowVersionHistory.get(name) ?? [];
45434592
history.push({
@@ -4933,6 +4982,60 @@ export class AutomationEngine implements IAutomationService {
49334982
return this.packagedFlowOwner(name) !== undefined;
49344983
}
49354984

4985+
/**
4986+
* [ADR-0150 D3] Attach the reader over the one package-scoped resolver
4987+
* ({@link FlowNameResolver}). The automation plugin calls this at `init()`
4988+
* with a reader that asks the ObjectQL registry at question time, never at
4989+
* boot, for {@link setPackagedFlowSource}'s reason.
4990+
*
4991+
* Two seams read it, and only these two:
4992+
*
4993+
* - {@link registerFlow} asks it for a caller with no package before it
4994+
* arms a name, so a name two packages hold is refused, naming both,
4995+
* instead of arming one of them — a deployment sealed at its cold boot
4996+
* with a clash that predates phase 1 included (#31677 Q1, ruled A).
4997+
* The engine keeps one armed slot per bare name, and nothing picks
4998+
* between two holders.
4999+
* - {@link execute} asks it for the caller's package: the package that
5000+
* ships the flow whose run names the child (a `subflow` or `map` node's
5001+
* `flowName`, carried on the run's context as `flowName`), or no
5002+
* package for a door, a trigger or any caller outside a run. A refusal
5003+
* is the answer, naming every candidate; nothing is executed.
5004+
*
5005+
* A name the registry holds nothing under (a flow registered straight into
5006+
* the engine) has one holder, the engine's own flow map, and resolves to
5007+
* it as before.
5008+
*/
5009+
setFlowNameResolver(resolver: FlowNameResolver | undefined): void {
5010+
this.flowNameResolver = resolver;
5011+
}
5012+
5013+
/**
5014+
* [ADR-0150 D3] The resolver's refusal of the bare flow name `name` for a
5015+
* caller of package `callerPackageId` (`null`: no package), or `undefined`
5016+
* when the name resolves, names nothing in the registry, or no resolver is
5017+
* attached. Read-only; records nothing.
5018+
*/
5019+
private flowNameRefusal(name: string, callerPackageId: string | null): PackageScopedRefusal | undefined {
5020+
const resolution = this.flowNameResolver?.(name, callerPackageId);
5021+
return resolution?.status === 'refused' ? resolution : undefined;
5022+
}
5023+
5024+
/**
5025+
* [ADR-0150 D3] The package a call of {@link execute} comes from: the
5026+
* package that ships the flow whose run made the call — `context.flowName`,
5027+
* which the engine stamps on every run's context
5028+
* (`resolveRunContext`), so a `subflow` or `map` child carries its parent's
5029+
* name — or `null` for a caller outside a run, and for a run of a flow no
5030+
* package ships (an environment-authored flow resolves as a caller with no
5031+
* package, ADR-0150 D3).
5032+
*/
5033+
private flowCallerPackage(context: AutomationContext | undefined): string | null {
5034+
const caller = context?.flowName;
5035+
if (typeof caller !== 'string' || caller === '') return null;
5036+
return this.packagedFlowOwner(caller) ?? null;
5037+
}
5038+
49365039
/**
49375040
* [#20790] Attach the write-only flow credential channel. The automation
49385041
* plugin calls this at `init()`, before any flow is registered. With none
@@ -6150,6 +6253,13 @@ export class AutomationEngine implements IAutomationService {
61506253

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

61556265
if (!flow) {

0 commit comments

Comments
 (0)