From f18988037bff75d0e359f030c95e98b32926803d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:55:57 +0000 Subject: [PATCH] fix(service-automation,types,objectql): a switched-off packaged flow is refused with the same plain sentence a switched-off action reads as MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FLOW_DISABLED ledger message named sys_metadata_activation and ADR-0126 §7.2 in a string the caller reads. It now reads as the one switched-off packaged item sentence, built by describeTurnedOffPackagedItem in @objectstack/types, which the ACTION_DISABLED refusal now reads from too (its wording unchanged). The code, the status and what is refused do not move; the table and the ADR go to an info log line written when the engine refuses. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cyizCEEdpjyVi8Kmw1 --- .../27966-flow-disabled-plain-sentence.md | 13 ++++ .../platform-checklist/areas/automation.json | 16 ++-- .../objectql/src/action-activation.test.ts | 7 ++ packages/objectql/src/action-activation.ts | 11 ++- .../service-automation/src/engine.test.ts | 7 +- .../services/service-automation/src/engine.ts | 16 ++++ .../src/flow-activation-ledger.test.ts | 76 +++++++++++++++++-- .../service-automation/src/subflow-guards.ts | 14 ++-- packages/types/src/index.ts | 4 + .../src/packaged-item-turned-off.test.ts | 27 +++++++ .../types/src/packaged-item-turned-off.ts | 42 ++++++++++ 11 files changed, 209 insertions(+), 24 deletions(-) create mode 100644 .changeset/27966-flow-disabled-plain-sentence.md create mode 100644 packages/types/src/packaged-item-turned-off.test.ts create mode 100644 packages/types/src/packaged-item-turned-off.ts diff --git a/.changeset/27966-flow-disabled-plain-sentence.md b/.changeset/27966-flow-disabled-plain-sentence.md new file mode 100644 index 00000000000..c5f8c54c440 --- /dev/null +++ b/.changeset/27966-flow-disabled-plain-sentence.md @@ -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. diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index 52a4d2c4c2a..96452b7bcaf 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -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": [ @@ -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 '' 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 '' 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" }, { @@ -1863,7 +1863,7 @@ "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", @@ -1871,7 +1871,7 @@ ], "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)", @@ -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" } ] }, diff --git a/packages/objectql/src/action-activation.test.ts b/packages/objectql/src/action-activation.test.ts index e40a4c01ef5..20dbba62ed6 100644 --- a/packages/objectql/src/action-activation.test.ts +++ b/packages/objectql/src/action-activation.test.ts @@ -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 { @@ -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', () => { diff --git a/packages/objectql/src/action-activation.ts b/packages/objectql/src/action-activation.ts index 616f3ff3f80..909aa1ef22d 100644 --- a/packages/objectql/src/action-activation.ts +++ b/packages/objectql/src/action-activation.ts @@ -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 @@ -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'); } } diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 030e916ac0d..25240c58aa3 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -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 () => { @@ -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 — diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 83ea311d762..9cebe49fe77 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -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 = { flowLedgerDisabled: this.flowLedgerDisabled, diff --git a/packages/services/service-automation/src/flow-activation-ledger.test.ts b/packages/services/service-automation/src/flow-activation-ledger.test.ts index 6529db4e8e2..d556edb1b0c 100644 --- a/packages/services/service-automation/src/flow-activation-ledger.test.ts +++ b/packages/services/service-automation/src/flow-activation-ledger.test.ts @@ -65,6 +65,11 @@ function packagedFlow(name: string, startConfig: Record = {}, 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 Promise>(); @@ -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); @@ -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' }); @@ -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 () => { diff --git a/packages/services/service-automation/src/subflow-guards.ts b/packages/services/service-automation/src/subflow-guards.ts index d603cf84b60..d3d7391c784 100644 --- a/packages/services/service-automation/src/subflow-guards.ts +++ b/packages/services/service-automation/src/subflow-guards.ts @@ -14,6 +14,7 @@ */ import type { FlowParsed } from '@objectstack/spec/automation'; +import { describeTurnedOffPackagedItem } from '@objectstack/types'; import type { AutomationEngine } from './engine.js'; /** @@ -43,15 +44,12 @@ export interface SubflowGuardsHost { readonly activateFlowTrigger: AutomationEngine['activateFlowTrigger']; } -/** The body of {@link AutomationEngine.describeDisabledFlow}, moved unedited; that method's docblock is its contract. */ +/** The body of {@link AutomationEngine.describeDisabledFlow}; that method's docblock is its contract. */ export function describeDisabledFlow(this: Pick, flowName: string): string { - if (this.flowLedgerDisabled.has(flowName)) { - return ( - `Flow '${flowName}' 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.` - ); - } + // [#27966] The ledger sentence is the one switched-off packaged item + // sentence the action refusal reads as too; the ledger table and the ADR + // go to the engine's log line, not to what the caller reads. + if (this.flowLedgerDisabled.has(flowName)) return describeTurnedOffPackagedItem('flow'); // Unchanged wording for the status dimension: it is what every existing // consumer and test reads, and this leg has no reason to move it. return `Flow '${flowName}' is disabled`; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 655ab1dac1c..60dfe3a81da 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -119,6 +119,10 @@ export * from './share-link-resolve-answer.js'; // with. Producer and consumer are two services neither of which may import // the other; both already depend on this package, so the seam adds no edge. export * from './connector-credential-source.js'; +// [#27966] The one sentence a refused switched-off packaged item reads as, +// action or flow. Its producers (objectql, service-automation) cannot import +// each other; both already depend on this package. +export * from './packaged-item-turned-off.js'; // Placeholder for Kernel interface to avoid circular dependency // The actual Kernel implementation will satisfy this interface. diff --git a/packages/types/src/packaged-item-turned-off.test.ts b/packages/types/src/packaged-item-turned-off.test.ts new file mode 100644 index 00000000000..3e04a4e1de8 --- /dev/null +++ b/packages/types/src/packaged-item-turned-off.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { describeTurnedOffPackagedItem } from './packaged-item-turned-off.js'; + +describe('describeTurnedOffPackagedItem — the one switched-off packaged item sentence (#27842 / #27966)', () => { + it('the action sentence is the one #27842 settled, byte for byte', () => { + expect(describeTurnedOffPackagedItem('action')).toBe( + '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.', + ); + }); + + it('the flow sentence has the same shape, and offers the copy flow clone charters', () => { + expect(describeTurnedOffPackagedItem('flow')).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.', + ); + }); + + it.each(['action', 'flow'] as const)('the %s sentence names no system table and no ADR', (kind) => { + const message = describeTurnedOffPackagedItem(kind); + expect(message).not.toContain('sys_metadata_activation'); + expect(message).not.toMatch(/ADR-\d+/); + expect(message).not.toMatch(/#\d+/); + }); +}); diff --git a/packages/types/src/packaged-item-turned-off.ts b/packages/types/src/packaged-item-turned-off.ts new file mode 100644 index 00000000000..5d3a3746ee9 --- /dev/null +++ b/packages/types/src/packaged-item-turned-off.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27842 / #27966] The one sentence a caller reads when a packaged item an + * administrator switched off under Setup › Packaged automation is refused + * (ADR-0126 §7.2 for flows, §8 for actions). + * + * It is written for the person who asked for the item, and it is what their + * error toast reads: what happened, who can undo it and where, and what can + * be done meanwhile. It carries no system table, no ADR and no tracker number + * (AGENTS.md: a runtime string carries the lesson, not its citation). The + * machine half stays where a machine reads it: the envelope's code + * (`ACTION_DISABLED` / `FLOW_DISABLED`) and the log line each engine writes + * when it refuses one, which names the activation ledger and the ADR. + * + * It lives here because its two producers cannot import each other: + * `@objectstack/objectql` answers the action refusal and + * `@objectstack/service-automation` the flow refusal, and both already depend + * on this package, so the one sentence adds no edge. + * + * The "meanwhile" clause is the one place the kinds differ, and it follows + * what is chartered. Flow clone is (ADR-0126 §7.2: `POST /automation/:name/clone`, + * offered on the same Setup page), so the flow sentence offers a copy. + * Action-clone is not (§8 item 2), so the action sentence ⛔ never does: + * recommending one would advertise machinery that does not exist. + * + * The item's name is not in the sentence: the caller named the item it asked + * for, and a machine name read in a toast is not plain language. + */ +export type PackagedItemKind = 'action' | 'flow'; + +const MEANWHILE: Record = { + action: 'or you can use another action', + flow: 'or copy it there under a new name', +}; + +export function describeTurnedOffPackagedItem(kind: PackagedItemKind): string { + return ( + `This ${kind} is turned off for this deployment. An administrator can turn it on again under ` + + `Setup › Packaged automation, ${MEANWHILE[kind]}.` + ); +}