Skip to content

Commit 645534d

Browse files
fix(service-automation,types,objectql): a switched-off packaged flow is refused with the same plain sentence a switched-off action reads as (#28097)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D95nzVteRYXwo88m23Tvxk2)_ domain:server#4 · os-elon-musk · session_016WV4cyizCEEdpjyVi8Kmw1 Fixes #27966 Clause-②: no Metadata-protocol: none Skills-docs: none — no authorable metadata changes; only the text of an existing refusal moves ## 维护者速读 - 改了什么:管理员在「设置 › 打包自动化」里关掉的打包流程,被调用时仍以 `FLOW_DISABLED` 拒绝,但提示改成一句给人看的话:「This flow is turned off for this deployment. An administrator can turn it on again under Setup › Packaged automation, or copy it there under a new name.」 - 为什么改:旧提示把系统表 `sys_metadata_activation` 和 ADR-0126 §7.2 写给终端用户看。分诊裁定复用 #27964 给动作定下的那一句,而不是再写一份。#27964 的句子是写死在 objectql 里的字符串,service-automation 运行时又不依赖 objectql,所以把这句抽成 `@objectstack/types` 里的一个构建器,动作和流程共用(席位已改认领扩面)。 - 风险与回滚:错误码、状态码、拒绝的范围都没变;动作那句逐字不变。按状态停用的流程仍是「Flow '…' is disabled」,两种原因照旧可区分。表名和 ADR 改记到一条 info 日志。回滚即还原这个 commit。 - 先看:`packages/types/src/packaged-item-turned-off.ts`、`packages/services/service-automation/src/engine.ts`(`describeDisabledFlow`)、`src/flow-activation-ledger.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run src/flow-activation-ledger.test.ts`,预期 49 个测试全绿。 - 需要你做的:无,席位落地。 ## Measured before the change (`origin/main` 2e955dd, scratch test, not committed) | door | `code` | `error` | |:--|:--|:--| | direct run (`engine.execute`, the run the REST trigger route calls) | `FLOW_DISABLED` | `Flow 'lone' is disabled — it is switched off for this installation in the packaged-metadata activation ledger (sys_metadata_activation, ADR-0126 §7.2). Re-enable the packaged flow to arm it again, or run a clone of it under a new name.` | | subflow (a parent calling a switched-off child) | — | `Node 'call' failed: subflow 'child' failed: Flow 'child' is disabled — it is switched off … (sys_metadata_activation, ADR-0126 §7.2). …` | | status-disabled flow (control, out of scope) | `FLOW_DISABLED` | `Flow 'obs' is disabled` | The REST trigger door forwards `result.error` verbatim as `error.message` (pinned at `packages/runtime/src/domains/automation-trigger-route-status.test.ts:285`), so it reads whatever the engine says. The retry exit in `dag-traversal.ts` asks the same `describeDisabledFlow` and is unreachable through `execute()` today (its own comment). ## Why a new builder in `@objectstack/types` Triage ruled that the flow sentence reuse "the one builder PR #27964 settles, not a second copy". Measured: #27964's sentence is a string literal inside `ActionActivationProjection.describeDisabled()` (`packages/objectql/src/action-activation.ts`), and `@objectstack/objectql` is only a devDependency of `@objectstack/service-automation`, so the flow engine cannot call it at runtime. Both packages already depend on `@objectstack/types`, so the sentence moved there as `describeTurnedOffPackagedItem('action' | 'flow')`. objectql's `describeDisabled()` now returns its action form, byte-identical to #27964's. The seat amended the claim's file surface for this. ## What changes - **`@objectstack/types`:** new `packaged-item-turned-off.ts` with `describeTurnedOffPackagedItem`, exported from the package entry. The two kinds share one shape and differ only in the last clause: flow clone is chartered (ADR-0126 §7.2, `POST /automation/:name/clone`, offered on the Setup page), so the flow sentence offers a copy; action-clone is not (§8 item 2), so the action sentence keeps "or you can use another action". - **`@objectstack/service-automation`:** the ledger branch of `describeDisabledFlow` (`subflow-guards.ts`) returns the flow sentence. The engine's `describeDisabledFlow` writes an `info` line naming the code, the flow, `sys_metadata_activation` and ADR-0126 §7.2 whenever it refuses a ledger-disabled flow (the #27964 log-line pattern). The status-dimension sentence is unchanged. - **`@objectstack/objectql`:** `describeDisabled()` delegates to the builder; its existing #27842 pin is untouched and still green. - **QA checklist:** `automation.packaged-flow-disable-durable` (revision 8) — acceptance[1] and negative[3] now key on the new sentence; the verdict is unchanged (the message still separates the ledger dimension from the status one, the code is still `FLOW_DISABLED`). - **Changeset:** patch for `@objectstack/service-automation`, `@objectstack/types`, `@objectstack/objectql`. ## Tests - `flow-activation-ledger.test.ts`: the four entry-path cases now pin the exact sentence; new cases pin the plain sentence (no table, no ADR) plus exactly one `FLOW_DISABLED:` info line naming the flow, the table and the ADR, and the subflow door (`Node 'call' failed: subflow 'child' failed: ` followed by the sentence, so the child is still named). The status-disabled case now asserts it never mentions Packaged automation. - `engine.test.ts`: two toggle cases follow the new wording. - `packages/types/src/packaged-item-turned-off.test.ts` (new, 4): both sentences byte for byte, neither names a table, an ADR or a tracker number. - `packages/objectql/src/action-activation.test.ts`: new pin that `describeDisabled()` IS the builder's action sentence. <details><summary>Ablations through scripts/ablation-replace.mjs (fix committed first)</summary> ``` ABLATE-1 subflow-guards.ts: ledger branch back to the old sentence × the four entry-path cases, × plain-sentence + log case, × subflow-door case, × engine.test "should disable a flow", × "leaves a run that never dispatched WITHOUT a status" Tests 8 failed | 205 passed (213) ok restored: blob == HEAD ABLATE-2 engine.ts: log line guard -> if (false) × [#27966] the ledger refusal reads as plain words for the caller, and the log line keeps the ledger and the ADR Tests 1 failed | 48 passed (49) ok restored: blob == HEAD ABLATE-3 objectql action-activation.ts: describeDisabled returns its own literal × [#27842] the refusal sentence is plain words …, × [#27966] … not a second copy of it Tests 2 failed | 20 passed (22) ok restored: blob == HEAD ``` </details> ## Verification (head 4ffb37d, `main` merged after #27951 landed) - vitest: `@objectstack/service-automation` 226 files / 2864 tests, `@objectstack/objectql` 494 / 9601, `@objectstack/types` 34 / 836 — all green. - `pnpm --filter PKG typecheck` (includes `check:test-typecheck`) exit 0 with PKG each of the three packages. - `dispatch-gates.mjs --commands`: 67 derived, 67 run, all exit 0 (`check:dual-build-cjs-loads` after building the 7 packages it named); `--ran` reconcile: 0 NOT-MEASURED, 0 UNRUN. - `pnpm lint` exit 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cyizCEEdpjyVi8Kmw1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47a8b58 commit 645534d

11 files changed

Lines changed: 209 additions & 24 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/types': patch
4+
'@objectstack/objectql': patch
5+
---
6+
7+
fix: a flow an administrator switched off is refused with a plain sentence, the same one a switched-off action reads as
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **The flow refusal** (`@objectstack/service-automation`). A packaged flow switched off under Setup › Packaged automation is still refused `FLOW_DISABLED`, at every door that runs it: its trigger, a direct run, and a parent flow that calls it as a subflow. The message now reads: "This flow is turned off for this deployment. An administrator can turn it on again under Setup › Packaged automation, or copy it there under a new name." It no longer names the ledger table or the ADR. The engine writes those, with the code and the flow's name, to an `info` log line each time it refuses one. A flow disabled by its own status (`obsolete` / `invalid`) still reads "Flow '…' is disabled", so the two reasons stay distinguishable.
13+
- **One sentence for both** (`@objectstack/types`, `@objectstack/objectql`). `describeTurnedOffPackagedItem('action' | 'flow')` builds the sentence. The action refusal (`ACTION_DISABLED`) reads from it too, and its wording is unchanged. Only the last clause differs: a flow can be copied under a new name, and an action cannot, so the action sentence still offers another action instead.

‎docs/qa/platform-checklist/areas/automation.json‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1801,7 +1801,7 @@
18011801
"title": "Disabling a packaged flow writes a durable sys_metadata_activation row that survives a cold restart with the trigger left unbound; re-enable UPDATES the row and restores firing",
18021802
"since": "v17",
18031803
"status": "active",
1804-
"revision": 7,
1804+
"revision": 8,
18051805
"priority": "P0",
18061806
"surface": "api",
18071807
"personas": [
@@ -1835,9 +1835,9 @@
18351835
"evidence": "the row read (the four ledger columns, and the key set showing no tenant column) + the toggle response"
18361836
},
18371837
{
1838-
"clause": "the FLOW_DISABLED refusal message names the LEDGER: triggering the disabled flow answers 409 FLOW_DISABLED whose message carries the sys_metadata_activation / activation-ledger phrasing and the two remedies (re-enable, or clone under a new name)",
1838+
"clause": "the FLOW_DISABLED refusal message says the flow was switched off by the deployment's packaged-automation switch, in plain words: triggering the disabled flow answers 409 FLOW_DISABLED whose message is the switched-off packaged flow sentence — the flow is turned off for this deployment, an administrator can turn it on again under Setup › Packaged automation, or copy it there under a new name — and names no system table and no ADR (the engine's info log line carries those)",
18391839
"oracle": "api",
1840-
"verify": "the step-5 error message contains 'packaged-metadata activation ledger (sys_metadata_activation' (engine.ts describeDisabledFlow). ⛔ The CODE alone is NOT the oracle: ADR-0126 §7.2 deliberately REUSES FLOW_DISABLED for the ledger dimension (no new ADR-0112 entry), and a status-disabled flow answers the same 409 FLOW_DISABLED with the old bare \"Flow '<name>' is disabled\" — the ledger-vs-status distinction rides the MESSAGE, so a verdict keyed on code alone cannot tell this item's dimension from the authoring one",
1840+
"verify": "the step-5 error message is exactly \"This flow is turned off for this deployment. An administrator can turn it on again under Setup › Packaged automation, or copy it there under a new name.\" (engine.ts describeDisabledFlow, built by describeTurnedOffPackagedItem in @objectstack/types — the one sentence the switched-off action refusal also reads as) and contains neither 'sys_metadata_activation' nor an ADR number. ⛔ The CODE alone is NOT the oracle: ADR-0126 §7.2 deliberately REUSES FLOW_DISABLED for the ledger dimension (no new ADR-0112 entry), and a status-disabled flow answers the same 409 FLOW_DISABLED with the old bare \"Flow '<name>' is disabled\" — the ledger-vs-status distinction rides the MESSAGE, so a verdict keyed on code alone cannot tell this item's dimension from the authoring one",
18411841
"evidence": "the full 409 body with the message quoted"
18421842
},
18431843
{
@@ -1863,15 +1863,15 @@
18631863
"a toggle answering 2xx with NO sys_metadata_activation row written is a FAIL on this boot — the engine has a legitimate ledger-less degraded mode, but it WARNS 'IN PROCESS ONLY … will NOT survive a restart' (engine.ts) and the showcase composition attaches the ledger, so silence plus no row means the durable write was skipped where it was promised",
18641864
"a restart that re-arms the flow (enabled or bound true, or a run row from the post-restart create) is a FAIL against the whole point of ADR-0126 §7.2 — it is the retired flowEnabled map's behaviour reappearing",
18651865
"a re-enable that DELETES the row is a FAIL of the row contract even though firing resumes — absence-means-active makes deletion look equivalent right up until something needs the recorded choice",
1866-
"a ledger-disabled flow's trigger answered 409 FLOW_DISABLED with the status dimension's bare sentence (\"Flow 'showcase_urgent_task_alert' is disabled\", with no 'packaged-metadata activation ledger (sys_metadata_activation' phrasing) is a FAIL: the refusal then reports the administrator's ledger choice as an authoring status, and the code alone cannot tell the two apart, because ADR-0126 §7.2 shares it between them by design"
1866+
"a ledger-disabled flow's trigger answered 409 FLOW_DISABLED with the status dimension's bare sentence (\"Flow 'showcase_urgent_task_alert' is disabled\", with no 'turned off for this deployment' sentence and no Setup › Packaged automation remedy is a FAIL: the refusal then reports the administrator's ledger choice as an authoring status, and the code alone cannot tell the two apart, because ADR-0126 §7.2 shares it between them by design"
18671867
],
18681868
"traps": [
18691869
"first-boot-cold-start",
18701870
"seed-data-thin"
18711871
],
18721872
"source": [
18731873
"docs/adr/0126-packaged-metadata-customization-model.md §4 (the generic ledger + row shape), §7.2 (the durable row replaces the process-local flowEnabled map; FLOW_DISABLED code reused, distinction rides the message)",
1874-
"packages/services/service-automation/src/engine.ts#toggleFlow (toggleFlow — durable row FIRST), (describeDisabledFlow — the ledger message), (hydrateFlowActivations), (registerFlow does not re-arm a ledger-disabled flow — restart survival's other half), (flowLedgerDisabled projection)",
1874+
"packages/services/service-automation/src/engine.ts#toggleFlow (toggleFlow — durable row FIRST), (describeDisabledFlow — the ledger message, built by packages/types/src/packaged-item-turned-off.ts and logged at info with the ledger table and ADR-0126 §7.2), (hydrateFlowActivations), (registerFlow does not re-arm a ledger-disabled flow — restart survival's other half), (flowLedgerDisabled projection)",
18751875
"packages/services/service-automation/src/plugin.ts (hydrate AFTER the boot flow pull — the ordering that makes the unbind stick)",
18761876
"packages/core/src/utils/metadata-activation-store.ts#setActive (setActive read-then-update, keyed on exactly the index's two columns; organization_id never in the write payload), (no delete on the engine slice)",
18771877
"packages/core/src/utils/metadata-activation-store.ts#list — list() is the whole read path, scoped by metadata_type and by nothing else. ⚠️ The org-row skip this line used to cite is RETIRED, not merely unexercised: the module header records that the NULL-organization filter and the org-row skip both went with the column (#15024)",
@@ -1924,6 +1924,12 @@
19241924
"date": "2026-10-27",
19251925
"change": "steps[1], steps[7] and steps[9] name the task's required title. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26481, priority:P0, at 756220c4; wave anchor #25807, slice A): the task POST needs `title` as well as `project`, and showcase_task has no `name` field (a body using it answers 400 INVALID_FIELD). Re-read at base e4ba846f (task.object.ts: title Field.text required). No clause changed",
19261926
"ref": "#25807"
1927+
},
1928+
{
1929+
"revision": 8,
1930+
"date": "2026-10-30",
1931+
"change": "acceptance[1] and negative[3] respelled for #27966: the ledger-disabled FLOW_DISABLED message is now written for the caller (the flow is turned off for this deployment, an administrator can turn it on again under Setup › Packaged automation, or copy it there under a new name) and names no system table and no ADR; the engine writes the code, the flow, sys_metadata_activation and ADR-0126 §7.2 to an info log line. It is the same builder the switched-off action sentence reads from (#27842). The verdict is unchanged: the message still distinguishes the ledger dimension from the status one, and the code is still FLOW_DISABLED.",
1932+
"ref": "#27966"
19271933
}
19281934
]
19291935
},

‎packages/objectql/src/action-activation.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { describe, it, expect, vi } from 'vitest';
3232
// from metadata-core rather than from `@objectstack/objectql`: this test lives
3333
// INSIDE objectql, and the package re-exporting it is this one.
3434
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
35+
import { describeTurnedOffPackagedItem } from '@objectstack/types';
3536

3637
import { ObjectQL } from './engine.js';
3738
import {
@@ -328,6 +329,12 @@ describe('ActionActivationProjection — absence means ACTIVE', () => {
328329
// "clone", and this one must not inherit that sentence.
329330
expect(message).not.toMatch(/clone/i);
330331
});
332+
333+
it('[#27966] the sentence is the shared switched-off packaged item sentence, not a second copy of it', () => {
334+
expect(new ActionActivationProjection().describeDisabled('convert_lead')).toBe(
335+
describeTurnedOffPackagedItem('action'),
336+
);
337+
});
331338
});
332339

333340
describe('ObjectQL engine — the projection the dispatch doors consult', () => {

‎packages/objectql/src/action-activation.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ import {
9696
type MetadataActivationStoreEngine,
9797
type MetadataActivationWriteOptions,
9898
} from '@objectstack/core';
99+
import { describeTurnedOffPackagedItem } from '@objectstack/types';
99100

100101
/**
101102
* The ledger table. A NAME, not an import: this package does not depend on
@@ -307,11 +308,13 @@ export class ActionActivationProjection {
307308
* The name is not in the sentence: the caller named the action it asked
308309
* for, and a machine name read in a toast is not plain language. The
309310
* parameter stays so the doors' call is unchanged.
311+
*
312+
* [#27966] The sentence itself is `describeTurnedOffPackagedItem`
313+
* (`@objectstack/types`), the one the flow refusal reads as too, so the
314+
* two switched-off packaged items are stated in one shape, not two that
315+
* agree today.
310316
*/
311317
describeDisabled(_name: string): string {
312-
return (
313-
'This action is turned off for this deployment. An administrator can turn it on again under ' +
314-
'Setup › Packaged automation, or you can use another action.'
315-
);
318+
return describeTurnedOffPackagedItem('action');
316319
}
317320
}

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,7 +1348,9 @@ describe('AutomationEngine - Execution History', () => {
13481348

13491349
const result = await engine.execute('test_flow');
13501350
expect(result.success).toBe(false);
1351-
expect(result.error).toContain('disabled');
1351+
expect(result.code).toBe('FLOW_DISABLED');
1352+
// [#27966] The switched-off packaged flow sentence.
1353+
expect(result.error).toContain('turned off');
13521354
});
13531355

13541356
it('should enable a disabled flow', async () => {
@@ -3260,7 +3262,8 @@ describe('#9378 — execute() classifies terminal exits for the trigger transpor
32603262
await engine.toggleFlow('disabled_flow', false);
32613263
const disabled = await engine.execute('disabled_flow');
32623264
expect(disabled.success).toBe(false);
3263-
expect(disabled.error).toContain('is disabled');
3265+
// [#27966] The toggle's refusal reads as the switched-off packaged flow sentence.
3266+
expect(disabled.error).toContain('is turned off for this deployment');
32643267
expect(disabled.status).toBeUndefined();
32653268

32663269
// 3. Malformed definition: no start node. Registration accepts it —

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4997,8 +4997,24 @@ export class AutomationEngine implements IAutomationService {
49974997
* The `FLOW_DISABLED` refusal MESSAGE, which is where the ledger-vs-status
49984998
* distinction rides (ADR-0126 §7.2 reuses the CODE deliberately, so the
49994999
* message is the only channel left).
5000+
*
5001+
* [#27966] The ledger sentence is written for the caller, in the shape
5002+
* the switched-off action refusal reads as (`describeTurnedOffPackagedItem`,
5003+
* `@objectstack/types`), so it names no table and no ADR. Those, with the
5004+
* code and the flow's name, go to the log line written here: both refusal
5005+
* exits ask for this message exactly when they refuse, so this is the one
5006+
* place every ledger refusal passes that holds the logger. It is `info`,
5007+
* not `warn`: the refusal is handed to the caller, and nothing is degraded
5008+
* (AGENTS.md "Degradation log levels").
50005009
*/
50015010
private describeDisabledFlow(flowName: string): string {
5011+
if (this.flowLedgerDisabled.has(flowName)) {
5012+
this.logger.info(
5013+
`FLOW_DISABLED: packaged flow '${flowName}' refused — it is switched off in the activation ledger ` +
5014+
'(sys_metadata_activation, ADR-0126 §7.2).',
5015+
{ flow: flowName, refusal: 'FLOW_DISABLED' },
5016+
);
5017+
}
50025018
// The body lives in ./subflow-guards.ts (#25222).
50035019
const host: ThisParameterType<typeof describeDisabledFlow> = {
50045020
flowLedgerDisabled: this.flowLedgerDisabled,

‎packages/services/service-automation/src/flow-activation-ledger.test.ts‎

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ function packagedFlow(name: string, startConfig: Record<string, unknown> = {}, e
6565
return { ...flowBody(name, startConfig, extra), _packageId: 'crm' };
6666
}
6767

68+
/** [#27966] What a caller reads for a flow switched off in the activation ledger. */
69+
const LEDGER_SENTENCE =
70+
'This flow is turned off for this deployment. An administrator can turn it on again under '
71+
+ 'Setup › Packaged automation, or copy it there under a new name.';
72+
6873
/** A recording trigger, so binding state is asserted for real rather than inferred. */
6974
function recordingTrigger(type: string) {
7075
const bound = new Map<string, (ctx: AutomationContext) => Promise<void>>();
@@ -169,9 +174,10 @@ describe('ADR-0126 §7.2 — a ledger-disabled flow refuses at the execute() sea
169174
// ADR-0126 §7.2 reuses the code deliberately — ⛔ no new ADR-0112
170175
// ledger entry — so the CODE must be the existing one...
171176
expect(refused.code).toBe('FLOW_DISABLED');
172-
// ...and the distinction has to ride the MESSAGE.
173-
expect(refused.error).toContain('sys_metadata_activation');
174-
expect(refused.error).toContain('ADR-0126');
177+
// ...and the distinction has to ride the MESSAGE. [#27966] It is
178+
// the one switched-off packaged item sentence, written for the
179+
// caller: no table and no ADR (those go to the log line).
180+
expect(refused.error).toBe(LEDGER_SENTENCE);
175181
// The trigger is unbound too, so it does not even fire (§7.2).
176182
expect(triggers[triggerKey].isBound('f')).toBe(false);
177183

@@ -221,6 +227,65 @@ describe('ADR-0126 §7.2 — a ledger-disabled flow refuses at the execute() sea
221227
expect(String(viaParent.error)).toContain('child');
222228
});
223229

230+
it('[#27966] the ledger refusal reads as plain words for the caller, and the log line keeps the ledger and the ADR', async () => {
231+
const logger = createTestLogger();
232+
const engine = withLoaderSetFromPull(new AutomationEngine(logger));
233+
engine.setFlowActivationStore(new InMemoryFlowActivationStore());
234+
engine.registerFlow('f', packagedFlow('f'));
235+
await engine.toggleFlow('f', false);
236+
237+
const refused = await engine.execute('f');
238+
239+
expect(refused.code).toBe('FLOW_DISABLED');
240+
// What happened, who can undo it and where, and what can be done
241+
// meanwhile: flow clone is chartered (ADR-0126 §7.2), so the copy is
242+
// offered, unlike the action sentence.
243+
expect(refused.error).toBe(
244+
'This flow is turned off for this deployment. An administrator can turn it on again under '
245+
+ 'Setup › Packaged automation, or copy it there under a new name.',
246+
);
247+
expect(refused.error).not.toContain('sys_metadata_activation');
248+
expect(refused.error).not.toMatch(/ADR-\d+/);
249+
250+
// The machine half: one info line per refusal, naming the code, the
251+
// flow, the ledger table and the ADR section.
252+
const lines = logger.info.mock.calls.filter((c: any[]) => String(c[0]).startsWith('FLOW_DISABLED:'));
253+
expect(lines).toHaveLength(1);
254+
expect(String(lines[0][0])).toContain("'f'");
255+
expect(String(lines[0][0])).toContain('sys_metadata_activation');
256+
expect(String(lines[0][0])).toContain('ADR-0126 §7.2');
257+
expect(lines[0][1]).toEqual({ flow: 'f', refusal: 'FLOW_DISABLED' });
258+
});
259+
260+
it('[#27966] the subflow door: a parent calling a switched-off child fails with the child named and the same sentence', async () => {
261+
const { engine } = engineWithLedger();
262+
registerSubflowNode(engine, { logger: createTestLogger(), getService: () => undefined } as any);
263+
engine.registerFlow('child', packagedFlow('child'));
264+
engine.registerFlow('parent', {
265+
...packagedFlow('parent'),
266+
nodes: [
267+
{ id: 'start', type: 'start', label: 'Start', config: {} },
268+
{ id: 'call', type: 'subflow', label: 'Call', config: { flowName: 'child' } },
269+
{ id: 'end', type: 'end', label: 'End' },
270+
],
271+
edges: [
272+
{ id: 'e1', source: 'start', target: 'call' },
273+
{ id: 'e2', source: 'call', target: 'end' },
274+
],
275+
});
276+
const store = new InMemoryFlowActivationStore();
277+
await store.setActive({ name: 'child', packageId: 'crm', active: false });
278+
engine.setFlowActivationStore(store);
279+
await engine.hydrateFlowActivations();
280+
281+
const viaParent = await engine.execute('parent');
282+
283+
expect(viaParent.success).toBe(false);
284+
// The sentence drops the flow's name; the subflow node's own prefix
285+
// still names which child refused.
286+
expect(String(viaParent.error)).toBe(`Node 'call' failed: subflow 'child' failed: ${LEDGER_SENTENCE}`);
287+
});
288+
224289
it('a STATUS-disabled flow keeps its original message — the two disable reasons stay distinguishable', async () => {
225290
const { engine } = engineWithLedger();
226291
engine.registerFlow('obsolete_flow', { ...packagedFlow('obsolete_flow'), status: 'obsolete' });
@@ -230,8 +295,9 @@ describe('ADR-0126 §7.2 — a ledger-disabled flow refuses at the execute() sea
230295
expect(refused.code).toBe('FLOW_DISABLED');
231296
expect(refused.error).toBe("Flow 'obsolete_flow' is disabled");
232297
// The point of the distinction: an operator reading this must not be
233-
// sent to the activation ledger for an authoring-state problem.
234-
expect(refused.error).not.toContain('sys_metadata_activation');
298+
// sent to the activation switch for an authoring-state problem.
299+
// [#27966] The switch is now named by its Setup page, not its table.
300+
expect(refused.error).not.toContain('Packaged automation');
235301
});
236302

237303
it('a ledger-disabled flow stays disabled across re-registration (publish / hot reload / boot pull)', async () => {

0 commit comments

Comments
 (0)