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
13 changes: 13 additions & 0 deletions .changeset/27966-flow-disabled-plain-sentence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/service-automation': patch
'@objectstack/types': patch
'@objectstack/objectql': patch
---

fix: a flow an administrator switched off is refused with a plain sentence, the same one a switched-off action reads as

Clause-②: no
Metadata-protocol: none

- **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.
- **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.
16 changes: 11 additions & 5 deletions docs/qa/platform-checklist/areas/automation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1801,7 +1801,7 @@
"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",
"since": "v17",
"status": "active",
"revision": 7,
"revision": 8,
"priority": "P0",
"surface": "api",
"personas": [
Expand Down Expand Up @@ -1835,9 +1835,9 @@
"evidence": "the row read (the four ledger columns, and the key set showing no tenant column) + the toggle response"
},
{
"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)",
"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)",
"oracle": "api",
"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",
"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",
"evidence": "the full 409 body with the message quoted"
},
{
Expand All @@ -1863,15 +1863,15 @@
"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",
"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",
"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",
"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"
"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"
],
"traps": [
"first-boot-cold-start",
"seed-data-thin"
],
"source": [
"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)",
"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)",
"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)",
"packages/services/service-automation/src/plugin.ts (hydrate AFTER the boot flow pull — the ordering that makes the unbind stick)",
"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)",
"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)",
Expand Down Expand Up @@ -1924,6 +1924,12 @@
"date": "2026-10-27",
"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",
"ref": "#25807"
},
{
"revision": 8,
"date": "2026-10-30",
"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.",
"ref": "#27966"
}
]
},
Expand Down
7 changes: 7 additions & 0 deletions packages/objectql/src/action-activation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { describe, it, expect, vi } from 'vitest';
// from metadata-core rather than from `@objectstack/objectql`: this test lives
// INSIDE objectql, and the package re-exporting it is this one.
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { describeTurnedOffPackagedItem } from '@objectstack/types';

import { ObjectQL } from './engine.js';
import {
Expand Down Expand Up @@ -328,6 +329,12 @@ describe('ActionActivationProjection — absence means ACTIVE', () => {
// "clone", and this one must not inherit that sentence.
expect(message).not.toMatch(/clone/i);
});

it('[#27966] the sentence is the shared switched-off packaged item sentence, not a second copy of it', () => {
expect(new ActionActivationProjection().describeDisabled('convert_lead')).toBe(
describeTurnedOffPackagedItem('action'),
);
});
});

describe('ObjectQL engine — the projection the dispatch doors consult', () => {
Expand Down
11 changes: 7 additions & 4 deletions packages/objectql/src/action-activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
type MetadataActivationStoreEngine,
type MetadataActivationWriteOptions,
} from '@objectstack/core';
import { describeTurnedOffPackagedItem } from '@objectstack/types';

/**
* The ledger table. A NAME, not an import: this package does not depend on
Expand Down Expand Up @@ -307,11 +308,13 @@ export class ActionActivationProjection {
* The name is not in the sentence: the caller named the action it asked
* for, and a machine name read in a toast is not plain language. The
* parameter stays so the doors' call is unchanged.
*
* [#27966] The sentence itself is `describeTurnedOffPackagedItem`
* (`@objectstack/types`), the one the flow refusal reads as too, so the
* two switched-off packaged items are stated in one shape, not two that
* agree today.
*/
describeDisabled(_name: string): string {
return (
'This action is turned off for this deployment. An administrator can turn it on again under ' +
'Setup › Packaged automation, or you can use another action.'
);
return describeTurnedOffPackagedItem('action');
}
}
7 changes: 5 additions & 2 deletions packages/services/service-automation/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1348,7 +1348,9 @@ describe('AutomationEngine - Execution History', () => {

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

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

// 3. Malformed definition: no start node. Registration accepts it —
Expand Down
16 changes: 16 additions & 0 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4988,8 +4988,24 @@ export class AutomationEngine implements IAutomationService {
* The `FLOW_DISABLED` refusal MESSAGE, which is where the ledger-vs-status
* distinction rides (ADR-0126 §7.2 reuses the CODE deliberately, so the
* message is the only channel left).
*
* [#27966] The ledger sentence is written for the caller, in the shape
* the switched-off action refusal reads as (`describeTurnedOffPackagedItem`,
* `@objectstack/types`), so it names no table and no ADR. Those, with the
* code and the flow's name, go to the log line written here: both refusal
* exits ask for this message exactly when they refuse, so this is the one
* place every ledger refusal passes that holds the logger. It is `info`,
* not `warn`: the refusal is handed to the caller, and nothing is degraded
* (AGENTS.md "Degradation log levels").
*/
private describeDisabledFlow(flowName: string): string {
if (this.flowLedgerDisabled.has(flowName)) {
this.logger.info(
`FLOW_DISABLED: packaged flow '${flowName}' refused — it is switched off in the activation ledger ` +
'(sys_metadata_activation, ADR-0126 §7.2).',
{ flow: flowName, refusal: 'FLOW_DISABLED' },
);
}
// The body lives in ./subflow-guards.ts (#25222).
const host: ThisParameterType<typeof describeDisabledFlow> = {
flowLedgerDisabled: this.flowLedgerDisabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ function packagedFlow(name: string, startConfig: Record<string, unknown> = {}, e
return { ...flowBody(name, startConfig, extra), _packageId: 'crm' };
}

/** [#27966] What a caller reads for a flow switched off in the activation ledger. */
const LEDGER_SENTENCE =
'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.';

/** A recording trigger, so binding state is asserted for real rather than inferred. */
function recordingTrigger(type: string) {
const bound = new Map<string, (ctx: AutomationContext) => Promise<void>>();
Expand Down Expand Up @@ -169,9 +174,10 @@ describe('ADR-0126 §7.2 — a ledger-disabled flow refuses at the execute() sea
// ADR-0126 §7.2 reuses the code deliberately — ⛔ no new ADR-0112
// ledger entry — so the CODE must be the existing one...
expect(refused.code).toBe('FLOW_DISABLED');
// ...and the distinction has to ride the MESSAGE.
expect(refused.error).toContain('sys_metadata_activation');
expect(refused.error).toContain('ADR-0126');
// ...and the distinction has to ride the MESSAGE. [#27966] It is
// the one switched-off packaged item sentence, written for the
// caller: no table and no ADR (those go to the log line).
expect(refused.error).toBe(LEDGER_SENTENCE);
// The trigger is unbound too, so it does not even fire (§7.2).
expect(triggers[triggerKey].isBound('f')).toBe(false);

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

it('[#27966] the ledger refusal reads as plain words for the caller, and the log line keeps the ledger and the ADR', async () => {
const logger = createTestLogger();
const engine = withLoaderSetFromPull(new AutomationEngine(logger));
engine.setFlowActivationStore(new InMemoryFlowActivationStore());
engine.registerFlow('f', packagedFlow('f'));
await engine.toggleFlow('f', false);

const refused = await engine.execute('f');

expect(refused.code).toBe('FLOW_DISABLED');
// What happened, who can undo it and where, and what can be done
// meanwhile: flow clone is chartered (ADR-0126 §7.2), so the copy is
// offered, unlike the action sentence.
expect(refused.error).toBe(
'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.',
);
expect(refused.error).not.toContain('sys_metadata_activation');
expect(refused.error).not.toMatch(/ADR-\d+/);

// The machine half: one info line per refusal, naming the code, the
// flow, the ledger table and the ADR section.
const lines = logger.info.mock.calls.filter((c: any[]) => String(c[0]).startsWith('FLOW_DISABLED:'));
expect(lines).toHaveLength(1);
expect(String(lines[0][0])).toContain("'f'");
expect(String(lines[0][0])).toContain('sys_metadata_activation');
expect(String(lines[0][0])).toContain('ADR-0126 §7.2');
expect(lines[0][1]).toEqual({ flow: 'f', refusal: 'FLOW_DISABLED' });
});

it('[#27966] the subflow door: a parent calling a switched-off child fails with the child named and the same sentence', async () => {
const { engine } = engineWithLedger();
registerSubflowNode(engine, { logger: createTestLogger(), getService: () => undefined } as any);
engine.registerFlow('child', packagedFlow('child'));
engine.registerFlow('parent', {
...packagedFlow('parent'),
nodes: [
{ id: 'start', type: 'start', label: 'Start', config: {} },
{ id: 'call', type: 'subflow', label: 'Call', config: { flowName: 'child' } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'call' },
{ id: 'e2', source: 'call', target: 'end' },
],
});
const store = new InMemoryFlowActivationStore();
await store.setActive({ name: 'child', packageId: 'crm', active: false });
engine.setFlowActivationStore(store);
await engine.hydrateFlowActivations();

const viaParent = await engine.execute('parent');

expect(viaParent.success).toBe(false);
// The sentence drops the flow's name; the subflow node's own prefix
// still names which child refused.
expect(String(viaParent.error)).toBe(`Node 'call' failed: subflow 'child' failed: ${LEDGER_SENTENCE}`);
});

it('a STATUS-disabled flow keeps its original message — the two disable reasons stay distinguishable', async () => {
const { engine } = engineWithLedger();
engine.registerFlow('obsolete_flow', { ...packagedFlow('obsolete_flow'), status: 'obsolete' });
Expand All @@ -230,8 +295,9 @@ describe('ADR-0126 §7.2 — a ledger-disabled flow refuses at the execute() sea
expect(refused.code).toBe('FLOW_DISABLED');
expect(refused.error).toBe("Flow 'obsolete_flow' is disabled");
// The point of the distinction: an operator reading this must not be
// sent to the activation ledger for an authoring-state problem.
expect(refused.error).not.toContain('sys_metadata_activation');
// sent to the activation switch for an authoring-state problem.
// [#27966] The switch is now named by its Setup page, not its table.
expect(refused.error).not.toContain('Packaged automation');
});

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