diff --git a/.changeset/20678-subflow-disable-sequence.md b/.changeset/20678-subflow-disable-sequence.md index 6f5e42946f0..10e8bd53c9d 100644 --- a/.changeset/20678-subflow-disable-sequence.md +++ b/.changeset/20678-subflow-disable-sequence.md @@ -18,7 +18,7 @@ Clause-②: no (narrowing) **Not refused:** - Enabling a flow that is already enabled. Nothing is re-armed. -- A flow the customer authored, or a subflow the customer authored. +- A subflow the customer authored. A flow the customer authored is not this switch's to enable at all: the activation switch switches packaged flows only, and it refuses a customer-authored flow for that reason before this guard is asked (see the entry "the toggle door refuses a flow no package ships, naming its status switch"). - A subflow in a cycle of switched-off flows with the flow being enabled, including a flow that calls itself. Each flow in such a cycle would refuse the others, so no order could complete. A subflow in such a cycle whose definition's `status` also disables it is still named, with its publish remedy: no enable order changes a status. The disable direction of the same guard is described in its own entry, "disabling a packaged subflow completes once its packaged callers are switched off and hold no parked run". diff --git a/.changeset/20726-clone-notice-status-switch.md b/.changeset/20726-clone-notice-status-switch.md new file mode 100644 index 00000000000..90e1284cd02 --- /dev/null +++ b/.changeset/20726-clone-notice-status-switch.md @@ -0,0 +1,7 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): the clone door's notice names the clone's own off-switch, its status (#20726) + +`POST /api/v1/automation/:name/clone` answers a `notice` saying the clone is armed. It told the admin to switch the clone off through `POST /api/v1/automation/NAME/toggle`. A clone carries no package envelope, so it is a flow authored in the deployment, and that switch refuses it: the switch turns packaged flows on and off only. The notice now names the clone's own switch: send its complete definition with `status: 'obsolete'` to `PUT /api/v1/automation/NAME`. It also says that the toggle switches packaged flows only and refuses the clone, whatever flow the clone was copied from. The response shape is unchanged; only the notice text moves. diff --git a/.changeset/20726-toggle-door-docs-client.md b/.changeset/20726-toggle-door-docs-client.md new file mode 100644 index 00000000000..da949d8df0e --- /dev/null +++ b/.changeset/20726-toggle-door-docs-client.md @@ -0,0 +1,7 @@ +--- +'@objectstack/client': patch +--- + +docs(client): `automation.toggle` says it switches packaged flows only, and names a customer flow's switch (#20726) + +`client.automation.toggle` had no docblock of its own: its one line had drifted above an unrelated member. It now says that it switches packaged flows only, and that a flow authored in the deployment is refused with 409 `RESOURCE_CONFLICT`. Such a flow's switch is its `status`, sent with the complete definition through `automation.update`. This is prose only: the method's signature and behaviour are unchanged. diff --git a/.changeset/20726-toggle-door-docs-spec.md b/.changeset/20726-toggle-door-docs-spec.md new file mode 100644 index 00000000000..bb9db6effc9 --- /dev/null +++ b/.changeset/20726-toggle-door-docs-spec.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): the Automation API docblock says the toggle door switches packaged flows only, and names a customer flow's switch (#20726) + +The module docblock of `api/automation-api.zod.ts` listed `POST /api/v1/automation/:name/toggle` as "Enable/disable flow". That file ships as source, and its docblock is also the source of the Automation API reference page. The line now reads "Enable/disable a packaged flow". A new paragraph says what a flow authored in the deployment uses instead: its `status`, published with the complete definition through `PUT /api/v1/automation/:name`. The toggle door refuses such a flow with 409 `RESOURCE_CONFLICT`. The `IAutomationService.toggleFlow` docblock, which read "Enable or disable a flow", says the same. This is prose only: no schema, type or export changes. diff --git a/.changeset/20726-toggle-door-packaged-only.md b/.changeset/20726-toggle-door-packaged-only.md new file mode 100644 index 00000000000..b62bdc63f06 --- /dev/null +++ b/.changeset/20726-toggle-door-packaged-only.md @@ -0,0 +1,23 @@ +--- +'@objectstack/service-automation': minor +--- + +fix(service-automation)!: the toggle door refuses a flow no package ships, naming its status switch (#20726) + +Clause-②: no (narrowing) + + + +**BREAKING**: shipped as `minor` under the launch-window convention. `toggleFlow(name, enabled)` on the automation service, and so `POST /api/v1/automation/:name/toggle` and `client.automation.toggle`, now switches packaged flows only: a flow a code package ships. + +**What was wrong.** The switch records an installation's choice in the packaged-metadata activation ledger (`sys_metadata_activation`, ADR-0126 §7.2), whose rows name the package that ships the flow. For a flow authored in the deployment it wrote a row anyway: + +- A flow with no package id, such as one created through `POST /api/v1/automation` or the clone door, was refused with 400 `VALIDATION_FAILED` "Package is required", naming a field the caller never sent. +- A flow carrying the runtime-row package sentinel or an app package id was accepted, and a ledger row was written for it. That gave it a second off-switch beside its own `status`. +- With no ledger attached, the flip was accepted in process only. + +**What changed.** A flow without package provenance is now refused with `RESOURCE_CONFLICT` / `409`, in both directions and with or without a ledger. The refusal comes before anything is written or changed. The message says the switch turns packaged flows on and off. It names the flow's own switch: its definition's `status`, published with the complete definition through `PUT /api/v1/automation/:name`. `obsolete` switches it off and `active` arms it. The switch never rewrites a definition itself. Packaged flows toggle exactly as before. + +**Migration.** To switch a customer-authored flow off, stop sending `POST /api/v1/automation/NAME/toggle` with `{"enabled": false}`. Instead, send `PUT /api/v1/automation/NAME` with the flow's complete definition and `status: 'obsolete'`, and `status: 'active'` to arm it again. In the SDK, `client.automation.toggle(name, false)` becomes `client.automation.update(name, { ...definition, status: 'obsolete' })`. + +**A customer flow that a ledger row already holds off.** If this switch turned a customer flow off before this release, its ledger row still holds the flow off after the upgrade, and no `status` clears that row. The refusal says so and names the step that completes: clone the flow under a new name through `POST /api/v1/automation/NAME/clone`, which arms the copy, then remove the old one. diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 486d8d43851..201706f90e5 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -25,6 +25,14 @@ the former `GET /api/v1/automation` list route, its request/response schemas and `client.automation.list` are retired (ADR-0087 semantic entry `automation-flow-list-route-retired`). +The toggle door switches PACKAGED flows only: a flow a code package ships. +It records the installation's choice in the packaged-metadata activation +ledger (ADR-0126 §7.2). A flow authored in the deployment is not switched +there. Its switch is its own `status`: `'obsolete'` disarms it and +`'active'` arms it, published with the complete definition through +`PUT /api/v1/automation/:name`. The toggle door refuses such a flow with +409 `RESOURCE_CONFLICT`, names that switch, and changes nothing. + **Endpoints** ``` GET /api/v1/automation/:name — Get flow @@ -32,7 +40,7 @@ POST /api/v1/automation — Create flow PUT /api/v1/automation/:name — Update flow DELETE /api/v1/automation/:name — Delete flow POST /api/v1/automation/:name/trigger — Trigger flow execution -POST /api/v1/automation/:name/toggle — Enable/disable flow +POST /api/v1/automation/:name/toggle — Enable/disable a packaged flow GET /api/v1/automation/:name/runs — List execution runs GET /api/v1/automation/:name/runs/:runId — Get single execution run ``` diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 19305adddbf..cabb6e00308 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -5483,9 +5483,6 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, - /** - * Enable or disable a flow - */ /* [#3563 PR-5] The three descriptor/status routes that had no SDK * expression — they back the Studio designer's pickers and badges. */ @@ -5525,6 +5522,17 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, + /** + * Enable or disable a PACKAGED flow — one a code package ships. + * + * [#20726, ADR-0126 §7.2] `POST /automation/:name/toggle` records the + * installation's choice in the packaged-metadata activation ledger, so + * it switches packaged flows only. A flow authored in the deployment is + * refused with 409 `RESOURCE_CONFLICT` and nothing changes. Its switch + * is its own `status`: send its complete definition through + * `automation.update(name, definition)` (`PUT /automation/:name`) with + * `status: 'obsolete'` to disarm it, or `status: 'active'` to arm it. + */ toggle: async (name: string, enabled: boolean): Promise<{ name: string; enabled: boolean }> => { const route = this.getRoute('automation'); const res = await this.fetch(`${this.baseUrl}${route}/${name}/toggle`, { diff --git a/packages/runtime/src/domains/automation-flow-clone.test.ts b/packages/runtime/src/domains/automation-flow-clone.test.ts index 2ef9ba8ac08..204040ea62f 100644 --- a/packages/runtime/src/domains/automation-flow-clone.test.ts +++ b/packages/runtime/src/domains/automation-flow-clone.test.ts @@ -456,7 +456,11 @@ describe('#12156 — the source, the notice, and the gate', () => { // `obsolete`/`invalid`, so a cloned record-change flow runs beside the // one it was copied from. Saying so is cheaper than the surprise. expect(notice).toMatch(/off-switch/i); - expect(notice).toContain('toggle'); + // [#20726] And the off-switch it names is one the clone has: its own + // `status`, through its update door. A clone carries no package + // envelope, and the activation toggle switches packaged flows only. + expect(notice).toContain("status: 'obsolete'"); + expect(notice).toContain('PUT /api/v1/automation/'); }); it('is an authoring write — a caller without `manage_metadata` is refused 403, nothing registered', async () => { diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 86c5c6f0b7f..c42cdf156ea 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -412,7 +412,10 @@ function isFlowEnablementWrite(parts: string[], method: string): boolean { * `POST /` → registerFlow (create) * `PUT /:name` → registerFlow (update) * `DELETE /:name` → unregisterFlow (deregister) - * `POST /:name/toggle` → toggleFlow (enablement — commit 266436a7f, see below) + * `POST /:name/toggle` → toggleFlow (enablement — commit 266436a7f, see below; + * PACKAGED flows only, #20726: a customer + * flow's switch is its `status`, via + * `PUT /:name`) * * ## [commit 266436a7f] Why `toggle` joins them — ruled, not inferred * @@ -1904,7 +1907,12 @@ export async function classifyResumeResult( * ran and failed → 400 `FLOW_FAILED`; #9378 + #9415; * a run that PAUSED → 200 with `runId` / `screen`, * on whichever attempt it paused — #9510) - * POST /:name/toggle → toggleFlow (unknown name → 404, #7535) + * POST /:name/toggle → toggleFlow (unknown name → 404, #7535). Switches + * PACKAGED flows only — it writes the ADR-0126 §7.2 + * activation ledger. A flow no package ships → 409 + * `RESOURCE_CONFLICT` naming that flow's own switch, + * its `status` ('obsolete' / 'active') published + * through `PUT /:name`; nothing changes (#20726) * ⚑ authoring write — `manage_metadata` (commit 266436a7f): * enablement is environment-wide, so an * unentitled toggle reached every organization diff --git a/packages/runtime/src/flow-clone.ts b/packages/runtime/src/flow-clone.ts index 5008277ee69..02479d7b5b1 100644 --- a/packages/runtime/src/flow-clone.ts +++ b/packages/runtime/src/flow-clone.ts @@ -147,14 +147,24 @@ export const FLOW_CLONE_DROPPED_KEYS: readonly string[] = Object.freeze([ * record-change flow and walks away has two flows running on one trigger, * and the only thing standing between them and that surprise is this * sentence. + * + * [#20726, ADR-0126 §7.2] The off-switch it names is the CLONE's own: its + * `status`, published through `PUT /:name`. A clone carries no package + * envelope (see {@link FLOW_CLONE_DROPPED_KEYS}), so it is a flow authored in + * this deployment, and the activation toggle — which switches packaged flows + * only — refuses it. That holds whatever the clone was copied from: the clone + * door takes any registered flow as its source, packaged or not, so the + * notice makes no claim about the source's provenance. */ export const FLOW_CLONE_NOTICE = 'References are not re-pointed: this clone calls exactly what the original called ' + '(subflows, actions and objects are unchanged). It is created with status `draft`, ' + 'which is a lifecycle label and NOT an off-switch — a cloned record-change or schedule ' + 'flow is bound to its trigger and will run alongside the flow it was copied from. ' - + 'Disable it (`POST /api/v1/automation//toggle` with `{"enabled": false}`) if that ' - + 'is not what you want.'; + + 'If that is not what you want, switch the clone off through its own status: send its ' + + 'complete definition with `status: \'obsolete\'` to `PUT /api/v1/automation/`. ' + + 'The activation toggle (`POST /api/v1/automation//toggle`) switches packaged flows ' + + 'only and refuses the clone.'; /** ADR-0112 envelope for the same-name refusal: a status AND a code. */ export const FLOW_CLONE_NAME_TAKEN_STATUS = 409; diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index f025c733844..720c02a949c 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -268,7 +268,15 @@ automation.registerFlow?.('escalate_high_priority_case', escalateCase); const names = await automation.listFlows(); // string[] of machine names const parsed = await automation.getFlow?.('escalate_high_priority_case'); -await automation.toggleFlow?.('escalate_high_priority_case', false); + +// A flow registered here is authored in the deployment, so its switch is its +// own `status`: re-register it with 'obsolete' to switch it off ('active' arms it). +automation.registerFlow?.('escalate_high_priority_case', { ...escalateCase, status: 'obsolete' }); + +// `toggleFlow` switches PACKAGED flows only — one a code package ships — by +// writing the ADR-0126 activation ledger. It refuses a flow no package ships +// with RESOURCE_CONFLICT / 409 and changes nothing. +await automation.toggleFlow?.('a_packaged_flow', false); ``` `registerFlow` validates against the live action registry and rejects unknown `config` @@ -292,7 +300,7 @@ GET /api/v1/automation/:name # get one flow PUT /api/v1/automation/:name # update a flow DELETE /api/v1/automation/:name # delete a flow POST /api/v1/automation/:name/trigger # execute a flow -POST /api/v1/automation/:name/toggle # enable / disable +POST /api/v1/automation/:name/toggle # enable / disable a packaged flow (a customer flow: its status, via PUT) GET /api/v1/automation/:name/runs # list runs GET /api/v1/automation/:name/runs/:runId # run detail GET /api/v1/automation/:name/runs/:runId/screen # screen spec of a parked run diff --git a/packages/services/service-automation/src/engine-residual-log-cause.test.ts b/packages/services/service-automation/src/engine-residual-log-cause.test.ts index f6b939d2ed9..95e32483600 100644 --- a/packages/services/service-automation/src/engine-residual-log-cause.test.ts +++ b/packages/services/service-automation/src/engine-residual-log-cause.test.ts @@ -424,7 +424,8 @@ describe('#6499 sites 5–8 — plugin-supplied code seams, all #4632 FUNCTIONAL const engine = new AutomationEngine(jsonLogger()); const trigger: FlowTrigger = { type: 'record_change', start() {}, stop() { throw new Error(MULTILINE_DRIVER); } }; engine.registerTrigger(trigger); - engine.registerFlow('rc_unbind', triggeredFlow('rc_unbind')); + // [#20726] Unbound through the toggle, which switches packaged flows only. + engine.registerFlow('rc_unbind', { ...triggeredFlow('rc_unbind'), _packageId: 'crm' }); const streams = await captureBoth(async () => { await engine.toggleFlow('rc_unbind', false); }); diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index c272aa4f4f4..5b92d3af822 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -1334,9 +1334,16 @@ describe('AutomationEngine - Execution History', () => { }); }); + /** + * [#20726, ADR-0126 §7.2] The toggle switches PACKAGED flows — a flow no + * package ships is refused, and its switch is its `status` — so every + * subject toggled below ships from a code package. + */ + const packagedSimpleFlow = { ...simpleFlow, _packageId: 'crm' }; + describe('toggleFlow', () => { it('should disable a flow', async () => { - engine.registerFlow('test_flow', simpleFlow); + engine.registerFlow('test_flow', packagedSimpleFlow); await engine.toggleFlow('test_flow', false); const result = await engine.execute('test_flow'); @@ -1345,7 +1352,7 @@ describe('AutomationEngine - Execution History', () => { }); it('should enable a disabled flow', async () => { - engine.registerFlow('test_flow', simpleFlow); + engine.registerFlow('test_flow', packagedSimpleFlow); await engine.toggleFlow('test_flow', false); await engine.toggleFlow('test_flow', true); @@ -1485,11 +1492,11 @@ describe('AutomationEngine - Execution History', () => { * wall's stated prohibition. */ it('keeps a ledger disable across unregister + re-register (§6 wall 3)', async () => { - engine.registerFlow('test_flow', simpleFlow); + engine.registerFlow('test_flow', packagedSimpleFlow); await engine.toggleFlow('test_flow', false); engine.unregisterFlow('test_flow'); - engine.registerFlow('test_flow', simpleFlow); + engine.registerFlow('test_flow', packagedSimpleFlow); const result = await engine.execute('test_flow'); expect(result.success).toBe(false); @@ -2920,7 +2927,8 @@ describe('AutomationEngine - Flow Trigger Wiring', () => { it('stops/restarts the binding when the flow is disabled/re-enabled', async () => { const rec = recordingTrigger('record_change'); engine.registerTrigger(rec.trigger); - engine.registerFlow('rc_flow', recordChangeFlow('rc_flow')); + // [#20726] The toggle switches packaged flows only. + engine.registerFlow('rc_flow', { ...recordChangeFlow('rc_flow'), _packageId: 'crm' }); await engine.toggleFlow('rc_flow', false); expect(rec.stopped).toEqual(['rc_flow']); @@ -3227,7 +3235,8 @@ describe('#9378 — execute() classifies terminal exits for the trigger transpor expect(missing.status).toBeUndefined(); // 2. Registered but disabled. - engine.registerFlow('disabled_flow', failingFlow('disabled_flow')); + // [#20726] Disabled through the toggle, which switches packaged flows only. + engine.registerFlow('disabled_flow', { ...failingFlow('disabled_flow'), _packageId: 'crm' }); await engine.toggleFlow('disabled_flow', false); const disabled = await engine.execute('disabled_flow'); expect(disabled.success).toBe(false); @@ -3264,7 +3273,8 @@ describe('#9378 — execute() classifies terminal exits for the trigger transpor * applied to one copy only is the shape a later reader mistakes for a rule. */ it('says WHICH refusal a never-dispatched exit is — code, and still no status', async () => { - engine.registerFlow('disabled_coded', failingFlow('disabled_coded')); + // [#20726] Disabled through the toggle, which switches packaged flows only. + engine.registerFlow('disabled_coded', { ...failingFlow('disabled_coded'), _packageId: 'crm' }); await engine.toggleFlow('disabled_coded', false); const disabled = await engine.execute('disabled_coded'); expect(disabled.success).toBe(false); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 8794236c4bd..1aea3ac4b60 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -5135,7 +5135,88 @@ export class AutomationEngine implements IAutomationService { } /** - * [ADR-0126 §7.2] Flip a flow's activation — THE sanctioned off-switch. + * [#20726, ADR-0126 §7.2] Refuse the toggle door for a flow no package + * ships — loudly, naming that flow's own switch, and before anything is + * written or changed. + * + * ## Why the door switches packaged flows only + * + * The door records an installation's CHOICE about a packaged artifact in + * `sys_metadata_activation`, whose rows each name "the package that ships + * the base artifact" (§4), and §7.2 makes that row "the sanctioned + * off-switch for packaged flows". A flow authored in this deployment has + * no such package, and it already has an off-switch of its own: its + * definition's `status` (`obsolete` / `invalid` disarm it, see + * {@link registerFlow}; {@link isFlowEnabled} composes the two). Writing + * it into the ledger anyway would give it a SECOND off-switch under a + * package key its declaration excludes — with an empty id the durable + * store refused the write with a validation error naming a field the + * caller never sent, and with a sentinel id it would record a package + * that ships nothing. + * + * ⛔ The door does not rewrite the definition's `status` itself either: + * that would make it a second write door into definitions. It names the + * flow's update door instead — `PUT /automation/:name`, which drives + * {@link registerFlow} with the complete definition. + * + * "No package provenance" is `describeFlowContender(flow).source !== + * 'package'` — the one discriminator the §7.3 guards already ask + * (`isCodeArtifactBody`, ADR-0029 D9.6), ⛔ never a second reading. So a + * runtime row carrying the `sys_metadata` sentinel, and a tenant-authored + * row bound to an app package, are refused exactly as a flow with no + * package envelope at all is: provenance decides, not whether a package + * id happens to be non-empty. + * + * ADR-0112 envelope: code AND status. `RESOURCE_CONFLICT` (409) is the + * standard catalog's "the request conflicts with the resource's current + * state" member — the flow exists and the request is well-formed, but the + * target's provenance does not admit the act — and it is the code this + * same door already answers for its other state conflict (the §7.3 + * enable guard), so the door speaks one dialect. ⛔ No new ledger entry is + * minted. Not a 400: nothing about the request is malformed. Not a 403: + * no caller could be authorized into it. Not `DELETE_RESTRICTED`: that + * member means dependencies. + * + * ## A customer flow a ledger row ALREADY holds off + * + * The ledger is keyed by name, and a row can already stand under a + * customer flow's name: this door used to accept a customer flow whose + * package id was non-empty (the sentinel and app-bound shapes above) and + * wrote one, and a customer overlay can shadow a packaged flow the ledger + * switched off. A status does not clear such a row ({@link isFlowEnabled} + * composes the two, neither overrides the other), so for that flow the + * refusal must not stop at "publish it `active`" — a step that completes + * nothing. It names the one that does, and the one the `FLOW_DISABLED` + * refusal already names for a ledger-held flow: a clone under a new name + * (ADR-0126 §7.1), which no row holds. Still nothing is written: which + * rows this door should clear is not this refusal's to decide. + */ + private refuseCustomerAuthoredToggle(name: string, flow: FlowParsed, enabled: boolean): void { + if (describeFlowContender(flow).source === 'package') return; + const updateDoor = `its update door, PUT /automation/${name}, which takes the complete definition`; + const ownSwitch = this.flowLedgerDisabled.has(name) + ? `This flow's own switch is its definition's status, published through ${updateDoor} — but it is ` + + `ALSO held off by an activation-ledger row recorded under the name '${name}' (for a packaged flow of ` + + `that name, or by this switch before it refused customer-authored flows), and no status clears that ` + + `row. To run this flow, clone it under a new name through POST /automation/${name}/clone, which ` + + `arms the copy, and remove this one.` + : `This flow's switch is its own definition's status: publish it through ${updateDoor}, with status ` + + `'obsolete' to switch it off or 'active' to arm it.`; + throw Object.assign( + new Error( + `Flow '${name}' cannot be ${enabled ? 'enabled' : 'disabled'} through this switch: the switch turns ` + + `packaged flows on and off, and '${name}' was authored in this deployment, not shipped by a ` + + `package. The switch records an installation's choice about a packaged flow in the activation ` + + `ledger (sys_metadata_activation, ADR-0126 §7.2). ${ownSwitch} Nothing was changed.`, + ), + { code: 'RESOURCE_CONFLICT', status: 409 }, + ); + } + + /** + * [ADR-0126 §7.2] Flip a PACKAGED flow's activation — THE sanctioned + * off-switch for packaged flows. A flow no package ships is refused + * ({@link refuseCustomerAuthoredToggle}); its switch is its `status`. * * ## What changed, and why the durable write is inside this method * @@ -5159,11 +5240,13 @@ export class AutomationEngine implements IAutomationService { * every other service method; the wire is the untrusted surface, and the * wire goes through the gate. * - * @throws when the flow is unknown, when §7.3's subflow guard refuses in - * either direction, when the durable suspended-run store cannot be - * listed while the disable guard judges a switched-off caller, or when - * the durable write fails — a reported flip that did not persist is the - * failure mode this whole leg exists to remove. + * @throws when the flow is unknown, when no package ships it + * (`RESOURCE_CONFLICT` / 409, before anything is written or changed), + * when §7.3's subflow guard refuses in either direction, when the + * durable suspended-run store cannot be listed while the disable guard + * judges a switched-off caller, or when the durable write fails — a + * reported flip that did not persist is the failure mode this whole leg + * exists to remove. */ async toggleFlow(name: string, enabled: boolean): Promise { const flow = this.flows.get(name); @@ -5171,6 +5254,14 @@ export class AutomationEngine implements IAutomationService { throw new Error(`Flow '${name}' not found`); } + // [#20726, ADR-0126 §7.2] FIRST, ahead of both §7.3 guards: a flow no + // package ships is not this door's to switch in either direction, so + // neither guard's question arises, the disable guard's run-store read + // is never awaited for it, and the refusal lands before the ledger + // write and before any in-process change — with a ledger attached or + // in the degraded mode without one. Nothing half-flips. + this.refuseCustomerAuthoredToggle(name, flow, enabled); + // [ADR-0126 §7.3] The subflow guard runs in BOTH directions, because // a subflow pair breaks from either end. Disabling a child breaks the // callers that still reach it — armed, or holding a parked run; 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 c50b9b16f02..31e6caff878 100644 --- a/packages/services/service-automation/src/flow-activation-ledger.test.ts +++ b/packages/services/service-automation/src/flow-activation-ledger.test.ts @@ -829,20 +829,30 @@ describe('ADR-0126 §7.3 (enable direction) — re-enabling a packaged caller is await expect(engine.toggleFlow('head', true)).resolves.toBeUndefined(); }); - it('a NON-packaged caller is not guarded — a tenant\'s own flow is theirs to arm', async () => { - const { engine } = runnableEngine(); + it('a NON-packaged caller is not guarded — a tenant\'s own flow is theirs to arm, through its status', async () => { + const { engine, triggers } = runnableEngine(); engine.registerFlow('shared_step', packagedFlow('shared_step')); await engine.toggleFlow('shared_step', false); - engine.registerFlow('my_own_process', { ...caller('my_own_process', ['shared_step']), _packageId: undefined }); - await engine.toggleFlow('my_own_process', false); - - await expect(engine.toggleFlow('my_own_process', true)).resolves.toBeUndefined(); + const own = (status: string) => ({ ...caller('my_own_process', ['shared_step']), _packageId: undefined, status }); + engine.registerFlow('my_own_process', own('obsolete')); + + // [#20726] This switch is not a customer flow's: it is refused for its + // provenance, before §7.3 is asked — so no subflow is named. + const thrown = await engine.toggleFlow('my_own_process', true).then(() => undefined, (e: unknown) => e); + expect(thrown).toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409 }); + expect((thrown as Error).message).not.toContain('shared_step'); + + // Its own switch arms it, onto the switched-off packaged subflow: + // §7.3 guards packaged callers only. + engine.registerFlow('my_own_process', own('active')); + expect(triggers.record_change.isBound('my_own_process')).toBe(true); }); it('a NON-packaged subflow does not guard a packaged caller', async () => { const { engine } = runnableEngine(); - engine.registerFlow('my_step', { ...packagedFlow('my_step'), _packageId: undefined }); - await engine.toggleFlow('my_step', false); + // [#20726] Switched off through its own switch, its status — the + // toggle door refuses a flow no package ships. + engine.registerFlow('my_step', { ...packagedFlow('my_step'), _packageId: undefined, status: 'obsolete' }); engine.registerFlow('vendor_process', caller('vendor_process', ['my_step'])); await engine.toggleFlow('vendor_process', false); diff --git a/packages/services/service-automation/src/flow-label-on-result.test.ts b/packages/services/service-automation/src/flow-label-on-result.test.ts index 04a0b46e615..fd4df09fdeb 100644 --- a/packages/services/service-automation/src/flow-label-on-result.test.ts +++ b/packages/services/service-automation/src/flow-label-on-result.test.ts @@ -282,7 +282,8 @@ describe('AutomationResult.flowLabel — the authored flow label rides the resul describe('absent — a refusal carrying `code`, or no registered flow', () => { it('never-dispatched refusals: FLOW_DISABLED and FLOW_NO_START_NODE', async () => { - engine.registerFlow('approve_orders', chain('approve_orders', PARENT_LABEL, [{ id: 'w', type: 'work' }]) as never); + // [#20726] Disabled through the toggle, which switches packaged flows only. + engine.registerFlow('approve_orders', { ...chain('approve_orders', PARENT_LABEL, [{ id: 'w', type: 'work' }]), _packageId: 'crm' } as never); await engine.toggleFlow('approve_orders', false); const disabled = await engine.execute('approve_orders'); expect(disabled.code).toBe('FLOW_DISABLED'); diff --git a/packages/services/service-automation/src/flow-terminal-messages.test.ts b/packages/services/service-automation/src/flow-terminal-messages.test.ts index c6e480dd57d..f59b1797db1 100644 --- a/packages/services/service-automation/src/flow-terminal-messages.test.ts +++ b/packages/services/service-automation/src/flow-terminal-messages.test.ts @@ -210,7 +210,8 @@ describe('#9414 — the boundary: which exits must NOT carry a message', () => { it('a NEVER-DISPATCHED exit carries neither — a disabled flow has no terminal state', async () => { const { engine } = engineWith('pass'); - engine.registerFlow('notify_owner', messageFlow('notify_owner') as never); + // [#20726] Disabled through the toggle, which switches packaged flows only. + engine.registerFlow('notify_owner', { ...messageFlow('notify_owner'), _packageId: 'crm' } as never); engine.toggleFlow('notify_owner', false); const result = await engine.execute('notify_owner'); diff --git a/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts b/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts index 67c84c65a5a..2c98462c68e 100644 --- a/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts +++ b/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts @@ -144,7 +144,8 @@ describe('#4792 — a never-sealed node-type vocabulary announces itself at the it('does not fire for an unknown or disabled flow name — the trigger is a real run', async () => { const lines: string[] = []; const engine = new AutomationEngine(loggerCapturing(lines)); - engine.registerFlow('off', trivialFlow('off')); + // [#20726] Disabled through the toggle, which switches packaged flows only. + engine.registerFlow('off', { ...trivialFlow('off'), _packageId: 'crm' }); await engine.toggleFlow('off', false); expect((await engine.execute('never_registered')).success).toBe(false); diff --git a/packages/services/service-automation/src/toggle-door-packaged-only.test.ts b/packages/services/service-automation/src/toggle-door-packaged-only.test.ts new file mode 100644 index 00000000000..c1a98c5bcff --- /dev/null +++ b/packages/services/service-automation/src/toggle-door-packaged-only.test.ts @@ -0,0 +1,247 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20726] ADR-0126 §7.2 — the toggle door switches PACKAGED flows, and only +// them. +// +// The door (`toggleFlow`, served as `POST /automation/:name/toggle`) records an +// installation's choice in the packaged-metadata activation ledger, +// `sys_metadata_activation`, whose rows each name "the package that ships the +// base artifact". A flow authored in the deployment has no such package, and +// it already has its own off-switch: its definition's `status` (`obsolete` +// disarms, `active` arms), published through its update door. So the door +// refuses a customer-authored flow loudly, BEFORE any ledger write and before +// any in-process change, and names that switch — in both directions, with a +// ledger attached or without one. ⛔ It never rewrites the definition itself: +// that would make it a second write door into definitions. +// +// Three pins, read off the door's own outputs — the refusal envelope, the +// ledger rows, the trigger binding and the `/_status` row — never off the +// guard's body: +// 1. a customer-authored flow toggled through the door gets the named +// refusal, and the ledger and the flow are unchanged — including one a +// ledger row already holds off, whose refusal names the step that +// completes for it; +// 2. a packaged flow still toggles (the control); +// 3. a customer flow published with `status: 'obsolete'` is not armed — the +// switch the refusal names works. + +import { describe, it, expect, vi } from 'vitest'; +import { AutomationEngine } from './engine.js'; +import type { FlowTrigger, FlowTriggerBinding } from './engine.js'; +import { InMemoryFlowActivationStore } from './flow-activation-store.js'; +import type { AutomationContext } from '@objectstack/spec/contracts'; + +function createTestLogger(): any { + const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + l.child = () => l; + return l; +} + +/** A recording trigger: binding state is read off the trigger, never inferred. */ +function recordingTrigger() { + const bound = new Map Promise>(); + const trigger: FlowTrigger = { + type: 'record_change', + start(binding: FlowTriggerBinding, cb: (ctx: AutomationContext) => Promise) { + bound.set(binding.flowName, cb); + }, + stop(flowName: string) { + bound.delete(flowName); + }, + }; + return { trigger, isBound: (n: string) => bound.has(n) }; +} + +/** A record-triggered flow, so whether it is armed shows on the trigger. */ +function flowBody(name: string, extra: Record = {}) { + return { + name, + label: name, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'lead', triggerType: 'record-after-create' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...extra, + }; +} + +/** Shipped by a code package (ADR-0029 D9.6 provenance). */ +const packaged = (name: string, extra: Record = {}) => flowBody(name, { ...extra, _packageId: 'crm' }); + +/** + * The three shapes a flow authored in this deployment reaches the engine in — + * none of them package provenance by the canonical test (`isCodeArtifactBody`, + * which the §7.3 guards already ask through `describeFlowContender`): + * - no package envelope at all (the automation create door, the clone door); + * - the `sys_metadata` sentinel a runtime-authored row is registered under; + * - a tenant-authored row bound to an app package (`_provenance: 'org'`). + * The second and third carry a non-empty `_packageId`, so the ledger's + * "Package is required" never refused them: they are the proof that the + * refusal reads provenance, not the emptiness of a package id. + */ +const CUSTOMER_SHAPES: Array<[string, Record]> = [ + ['no package envelope', {}], + ["the 'sys_metadata' runtime-row sentinel", { _packageId: 'sys_metadata' }], + ['a tenant-authored row bound to an app package', { _packageId: 'app.crm', _provenance: 'org' }], +]; + +/** The row `GET /automation/_status` serves for one flow. */ +function stateOf(engine: AutomationEngine, name: string) { + return engine.getFlowRuntimeStates().find((s) => s.name === name); +} + +/** An engine with the in-memory ledger and a record trigger attached. */ +function engineWithLedger() { + const logger = createTestLogger(); + const engine = new AutomationEngine(logger); + const store = new InMemoryFlowActivationStore(); + engine.setFlowActivationStore(store); + const records = recordingTrigger(); + engine.registerTrigger(records.trigger); + return { engine, store, records, logger }; +} + +/** The refusal, caught, so its envelope can be read. */ +async function refusalOf(p: Promise): Promise { + try { + await p; + } catch (e) { + return e as Error & { code?: unknown; status?: unknown }; + } + throw new Error('expected the toggle door to refuse, and it accepted'); +} + +describe('[#20726] pin 1 — the toggle door refuses a customer-authored flow, and changes nothing', () => { + for (const [shape, envelope] of CUSTOMER_SHAPES) { + for (const enabled of [false, true]) { + it(`${shape}, enabled: ${enabled} — RESOURCE_CONFLICT / 409 naming its status switch; the ledger and the flow are unchanged`, async () => { + const { engine, store, records } = engineWithLedger(); + engine.registerFlow('customer_flow', flowBody('customer_flow', envelope)); + const setActive = vi.spyOn(store, 'setActive'); + const before = stateOf(engine, 'customer_flow'); + expect(before).toMatchObject({ enabled: true, bound: true }); + + const refusal = await refusalOf(engine.toggleFlow('customer_flow', enabled)); + + // ADR-0112 envelope: code AND status. + expect(refusal.code).toBe('RESOURCE_CONFLICT'); + expect(refusal.status).toBe(409); + // The named subjects: what the door switches, and the flow's + // own switch — its status, through its update door. + expect(refusal.message).toMatch(/packaged flows/); + expect(refusal.message).toMatch(/status/); + expect(refusal.message).toContain('PUT /automation/customer_flow'); + + // Nothing was written, and nothing moved in process. + expect(setActive).not.toHaveBeenCalled(); + expect(await store.list()).toEqual([]); + expect(stateOf(engine, 'customer_flow')).toEqual(before); + expect(records.isBound('customer_flow')).toBe(true); + expect((await engine.execute('customer_flow')).success).toBe(true); + }); + } + } + + it('with NO ledger attached (the degraded mode) the refusal is the same, and nothing flips in process', async () => { + const logger = createTestLogger(); + const engine = new AutomationEngine(logger); + const records = recordingTrigger(); + engine.registerTrigger(records.trigger); + engine.registerFlow('customer_flow', flowBody('customer_flow')); + const before = stateOf(engine, 'customer_flow'); + + const refusal = await refusalOf(engine.toggleFlow('customer_flow', false)); + + expect(refusal.code).toBe('RESOURCE_CONFLICT'); + expect(refusal.status).toBe(409); + expect(stateOf(engine, 'customer_flow')).toEqual(before); + expect(records.isBound('customer_flow')).toBe(true); + expect((await engine.execute('customer_flow')).success).toBe(true); + // The degraded-mode "IN PROCESS ONLY" flip was never reached. + const warned = logger.warn.mock.calls.map((c: unknown[]) => String(c[0])); + expect(warned.some((m: string) => m.includes('IN PROCESS ONLY'))).toBe(false); + }); + + // A customer flow can ALREADY be held off by a ledger row under its name: + // the door used to accept a flow whose package id was non-empty (the + // sentinel and app-bound shapes above) and wrote one, and a customer + // overlay can shadow a packaged flow the ledger switched off. A status + // does not clear such a row, so naming only the status would prescribe a + // step that completes nothing. The refusal names the step that does. + it('a customer flow a ledger row already holds off: still refused, the row untouched, and the step it names completes', async () => { + const { engine, store, records } = engineWithLedger(); + await store.setActive({ name: 'customer_flow', packageId: 'sys_metadata', active: false }); + engine.registerFlow('customer_flow', flowBody('customer_flow', { _packageId: 'sys_metadata', status: 'active' })); + await engine.hydrateFlowActivations(); + const before = stateOf(engine, 'customer_flow'); + expect(before).toMatchObject({ enabled: false, bound: false }); + + const refusal = await refusalOf(engine.toggleFlow('customer_flow', true)); + + expect(refusal.code).toBe('RESOURCE_CONFLICT'); + expect(refusal.status).toBe(409); + expect(refusal.message).toContain('PUT /automation/customer_flow'); + expect(refusal.message).toContain('POST /automation/customer_flow/clone'); + expect(await store.list()).toEqual([{ name: 'customer_flow', packageId: 'sys_metadata', active: false }]); + expect(stateOf(engine, 'customer_flow')).toEqual(before); + + // Its status does not arm it — which is why the refusal cannot stop there… + engine.registerFlow('customer_flow', flowBody('customer_flow', { _packageId: 'sys_metadata', status: 'active' })); + expect(records.isBound('customer_flow')).toBe(false); + // …and the named step completes: the clone door registers the whole + // definition under a new name, with no package envelope, and it is armed. + engine.registerFlow('customer_flow_copy', flowBody('customer_flow_copy', { status: 'draft' })); + expect(records.isBound('customer_flow_copy')).toBe(true); + }); +}); + +describe('[#20726] pin 2 — a packaged flow still toggles (the control)', () => { + it('disable writes its ledger row and disarms it; enable updates the row and re-arms it', async () => { + const { engine, store, records } = engineWithLedger(); + engine.registerFlow('shipped_flow', packaged('shipped_flow')); + expect(records.isBound('shipped_flow')).toBe(true); + + await engine.toggleFlow('shipped_flow', false); + + expect(await store.list()).toEqual([{ name: 'shipped_flow', packageId: 'crm', active: false }]); + expect(records.isBound('shipped_flow')).toBe(false); + expect(stateOf(engine, 'shipped_flow')).toMatchObject({ enabled: false, bound: false }); + const refused = await engine.execute('shipped_flow'); + expect(refused.success).toBe(false); + expect((refused as { code?: string }).code).toBe('FLOW_DISABLED'); + + await engine.toggleFlow('shipped_flow', true); + + expect(await store.list()).toEqual([{ name: 'shipped_flow', packageId: 'crm', active: true }]); + expect(records.isBound('shipped_flow')).toBe(true); + expect(stateOf(engine, 'shipped_flow')).toMatchObject({ enabled: true, bound: true }); + }); +}); + +describe("[#20726] pin 3 — the switch the refusal names works: a customer flow published with status 'obsolete' is not armed", () => { + it("publishing 'obsolete' through the registration path disarms it, and 'active' arms it again — the ledger is never written", async () => { + const { engine, store, records } = engineWithLedger(); + const setActive = vi.spyOn(store, 'setActive'); + // `PUT /automation/:name` drives exactly this: `registerFlow(name, definition)`. + engine.registerFlow('customer_flow', flowBody('customer_flow', { status: 'active' })); + expect(records.isBound('customer_flow')).toBe(true); + + engine.registerFlow('customer_flow', flowBody('customer_flow', { status: 'obsolete' })); + + expect(records.isBound('customer_flow')).toBe(false); + expect(stateOf(engine, 'customer_flow')).toMatchObject({ enabled: false, bound: false, status: 'obsolete' }); + const refused = await engine.execute('customer_flow'); + expect(refused.success).toBe(false); + expect((refused as { code?: string }).code).toBe('FLOW_DISABLED'); + + engine.registerFlow('customer_flow', flowBody('customer_flow', { status: 'active' })); + + expect(records.isBound('customer_flow')).toBe(true); + expect(stateOf(engine, 'customer_flow')).toMatchObject({ enabled: true, bound: true, status: 'active' }); + expect((await engine.execute('customer_flow')).success).toBe(true); + expect(setActive).not.toHaveBeenCalled(); + expect(await store.list()).toEqual([]); + }); +}); diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index 720ad6ffa5d..62a1b6b4218 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -25,6 +25,14 @@ import { ExecutionLogSchema, ExecutionStatus, FlowRunSummarySchema } from '../au * and `client.automation.list` are retired (ADR-0087 semantic entry * `automation-flow-list-route-retired`). * + * The toggle door switches PACKAGED flows only: a flow a code package ships. + * It records the installation's choice in the packaged-metadata activation + * ledger (ADR-0126 §7.2). A flow authored in the deployment is not switched + * there. Its switch is its own `status`: `'obsolete'` disarms it and + * `'active'` arms it, published with the complete definition through + * `PUT /api/v1/automation/:name`. The toggle door refuses such a flow with + * 409 `RESOURCE_CONFLICT`, names that switch, and changes nothing. + * * @example Endpoints * ``` * GET /api/v1/automation/:name — Get flow @@ -32,7 +40,7 @@ import { ExecutionLogSchema, ExecutionStatus, FlowRunSummarySchema } from '../au * PUT /api/v1/automation/:name — Update flow * DELETE /api/v1/automation/:name — Delete flow * POST /api/v1/automation/:name/trigger — Trigger flow execution - * POST /api/v1/automation/:name/toggle — Enable/disable flow + * POST /api/v1/automation/:name/toggle — Enable/disable a packaged flow * GET /api/v1/automation/:name/runs — List execution runs * GET /api/v1/automation/:name/runs/:runId — Get single execution run * ``` diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 002654f2027..12d33b6bc0c 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -716,7 +716,12 @@ export interface IAutomationService { getFlow?(name: string): Promise; /** - * Enable or disable a flow + * Enable or disable a PACKAGED flow — one a code package ships — by + * recording the installation's choice in the activation ledger + * (ADR-0126 §7.2). A flow authored in the deployment is not this switch's: + * the automation service refuses it with `RESOURCE_CONFLICT` / 409 and + * changes nothing. That flow's switch is its own `status` (`'obsolete'` / + * `'active'`), published through {@link registerFlow}. * @param name - Flow name (snake_case) * @param enabled - Whether to enable (true) or disable (false) */