Skip to content

Commit c8111a5

Browse files
fix(service-automation)!: the toggle door switches packaged flows only; a customer flow is refused, naming its status switch (#20726) (#20780)
Fixes #20726 Clause-②: no (narrowing) ## What this does `toggleFlow` is the automation service's activation switch. It is served as `POST /api/v1/automation/NAME/toggle` and called by `client.automation.toggle`. It now switches **packaged flows only**, as triage's direction on this card reads ADR-0126 §4 and §7.2 (option 1, comment `5901337034`). - **Refuse, don't write.** A flow without package provenance is refused with `RESOURCE_CONFLICT` / `409`. That holds in both directions, and with or without an activation ledger attached. The refusal is the first thing `toggleFlow` does after the unknown-flow check. It runs ahead of both ADR-0126 §7.3 guards, the ledger write and any in-process change, so nothing half-flips. - **The refusal names the flow's own switch.** It says the switch turns packaged flows on and off. It names the customer flow's switch: its definition's `status`, published through its update door, `PUT /automation/NAME`, which takes the complete definition. `obsolete` switches it off and `active` arms it. - **The door does not write the definition.** It never rewrites a customer flow's `status` itself. - **Packaged flows toggle exactly as before.** The refusal as the wire carries it, measured at the dispatcher seam (`HttpDispatcher.handleAutomation`, the real engine, a flow with no package envelope): ```text code: RESOURCE_CONFLICT · status: 409 Flow 'customer_flow' cannot be disabled through this switch: the switch turns packaged flows on and off, and 'customer_flow' 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). This flow's switch is its own definition's status: publish it through its update door, PUT /automation/customer_flow, which takes the complete definition, with status 'obsolete' to switch it off or 'active' to arm it. Nothing was changed. ``` The transport maps that thrown shape (`err.status`, `err.code`) to the HTTP answer in `errorResponseBase`. #20678's disable-half dev measured this live, on this door, for the same `Object.assign(new Error(…), { code, status })` shape. ### The code: `RESOURCE_CONFLICT` / 409 (G3) Chosen from the standard catalog (`StandardErrorCode`). No ledger entry is minted, and `pnpm check:error-code-casing` is green. - The flow exists and the request is well-formed, but the target's provenance does not admit the act. That is the catalog's "the request conflicts with the resource's current state" member. - This same door already answers `RESOURCE_CONFLICT` / 409 for its other state conflict, the §7.3 enable guard. The door keeps one dialect. - The other members do not fit: - not a 400 (`VALIDATION_ERROR`): nothing in the request is malformed, and today's 400 is the defect; - not a 403: no caller could be authorized into it; - not a 404: the flow exists; - not `DELETE_RESTRICTED`: that member means dependencies; - not `METHOD_NOT_ALLOWED`: the route serves the method. ### A customer flow that a ledger row already holds off The ledger is keyed by name, so a row can already stand under a customer flow's name, in two ways: - The door used to **accept** a customer flow whose package id was non-empty: the `sys_metadata` runtime-row sentinel, or a tenant-authored row bound to an app package. Pin 1 at base shows both were accepted, and it wrote a row for each. - A customer overlay can shadow a packaged flow that the ledger switched off. A `status` does not clear such a row: `isFlowEnabled` composes the two, and neither overrides the other. Measured at the engine seam: 1. After a boot that reads such a row, the flow is `enabled: false`. 2. The door refuses it. 3. Republishing it `active` leaves it `enabled: false`. So for that flow alone, a refusal that stopped at "publish it `active`" would name a step that completes nothing. In that state the refusal says the row holds the flow off. It names the step that does complete, which the `FLOW_DISABLED` refusal already names for a ledger-held flow: clone it under a new name (`POST /automation/NAME/clone`), which arms the copy, then remove the old one. **Still nothing is written.** Whether the door should instead clear such a row is not this refusal's to decide. It is the first open question in the dev report. ## Measured premises (G1 to G7) - **G1 holds.** "No package provenance" is `describeFlowContender(flow).source !== 'package'` (`isCodeArtifactBody`), the discriminator the §7.3 guards already ask. No second reading was added. - The measurement widened the premise: provenance, not an empty package id, decides. - A flow with the `sys_metadata` sentinel or a tenant-authored app-bound row carries a non-empty `_packageId`, so the ledger's "Package is required" never refused it. At base it toggled and wrote a row. - Pin 1 covers all three shapes. - **G2 holds.** `toggleFlow` wrote the ledger row first, with `packageId: String(flow._packageId ?? '')`. The refusal sits before that write and before the in-process change. - Degraded mode (no `flowActivationStore`): at base a customer flow flipped in process only, with the `IN PROCESS ONLY` warning. Now it is refused identically, nothing moves, and the warning is never reached (pin 1, degraded case). - **G3:** above. - **G4 holds, with one boundary.** The door is `PUT /:name` in `packages/runtime/src/domains/automation.ts` (`updateFlow`). It calls `registerFlow(name, definition)` with the complete definition. The refusal names it as #20678's refusals name doors (`PUT /automation/NAME`). Pin 3 drives that registration path. - The boundary, read from source and not measured live: this door re-registers the definition **in process**. The route calls `registerFlow`, and the engine persists no definition. A flow whose definition lives on the metadata plane re-registers its stored definition at the next boot or metadata reload. Dev report, open question 2. - **G5:** three lanes, prose only (below). The `.mdx` was regenerated, not hand-edited. - **G6:** below, measured on the built output. - **G7:** `Clause-②: no (narrowing)`, measured on this diff. It is not copied from the claim. - **Nothing widens.** No new export, member, key or accepted input. The spec and client edits are docblock prose. - **The accept set narrows**: - a customer flow with a non-empty package id was accepted, and is now refused; - with no ledger attached, a customer flow's flip was accepted in process, and is now refused; - a customer flow with no package id changes refusal: 400 `VALIDATION_FAILED` becomes 409 `RESOURCE_CONFLICT`. - So the declaration is `no (narrowing)`, and the service-automation changeset is `minor` with a BREAKING banner. ## Pins: red first, and an ablation for each New file `packages/services/service-automation/src/toggle-door-packaged-only.test.ts`, beside the ledger suite. - **Pin 1.** A customer-authored flow toggled through the door gets the named refusal, and the ledger and the flow are unchanged. It asserts: - `code` and `status`; - the named subjects: packaged flows, `status`, and `PUT /automation/NAME`; - `setActive` never called and the ledger rows unchanged; - `/_status` state unchanged, the trigger still bound, and `execute` still running. Cases: three provenance shapes × both directions, the no-ledger degraded mode, and a customer flow that a ledger row already holds off. - **Pin 2 (the control).** A packaged flow still toggles: row written, disarmed, `FLOW_DISABLED`, then re-enabled and re-armed. - **Pin 3.** A customer flow published with `status: 'obsolete'` through the registration path is not armed, and `active` arms it again. The ledger is never written. Order of commits: - `9c9eb7b6c` pins red: `Tests 7 failed | 2 passed (9)`. Each failure read "expected the toggle door to refuse, and it accepted". - `7b35222ec` the fix. - `c11a4f1a6` the held-off pin, red: `1 failed | 9 passed (10)`, on the missing clone step. - `94a4e3af6` its message branch. Ablations: `scripts/ablation-replace.mjs` wrap mode, run from the committed state, with an outer `trap` on EXIT/INT/TERM restoring by absolute path. In every leg the anchor hit 1 → 0, the blob changed, and the restore read "blob == HEAD and `git diff HEAD` is empty". There is no dist leg: the pins import `./engine.js` relatively, so they resolve `src`. | leg | mutation | red | |:--|:--|:--| | M1 | refusal call deleted | 8 (all of pin 1) | | M2 | provenance test inverted | 9 (pin 1, and pin 2, the control) | | M3 | `obsolete` no longer disables | 1 (pin 3) | | M4 | ledger-held branch dropped | 1 (the held-off pin) | | M5 | clone notice back to the toggle | 1 (the clone notice pin, runtime) | ## Docs: three lanes, prose only (G5) - `packages/spec/src/api/automation-api.zod.ts` module docblock. - The toggle line reads "Enable/disable a packaged flow". - A new paragraph says which flows the door switches and what a customer flow uses instead. - `content/docs/references/api/automation-api.mdx` comes from `pnpm --filter @objectstack/spec check:generated --fix`: exactly one stale artifact, regenerated from a dist that run built. - `client.automation.toggle`. Its one-line docblock had drifted above an unrelated member (`listActions`). It is moved back onto `toggle` and says which flows the door switches. - The route's description in `packages/runtime/src/domains/automation.ts`: the route list on `handleAutomationRequest` and the authoring-write predicate's list. - `content/docs/releases/**` is untouched. ## Changesets, one per package whose published bytes move (G6) Each marker was grepped over the package's `files[]` after a build, with a control phrase from the same file. | package | level | evidence | |:--|:--|:--| | `@objectstack/service-automation` | `minor`, BREAKING | behaviour; the refusal text is in `dist/index.js` and `dist/index.cjs` (2 files; control 2) | | `@objectstack/spec` | `patch` | the module docblock is in `src/api/automation-api.zod.ts`, shipped by `files[]` (`src/**/*.zod.ts`); the contract docblock is in `dist/contracts/index.d.ts` and `.d.mts` (control 2) | | `@objectstack/client` | `patch` | the docblock is in `dist/index.d.ts`, `.d.mts`, `index.js` and `index.mjs` (4; control 4) | | `@objectstack/runtime` | `patch` | only for the clone notice (below). The route docblocks reach no published file: 0 hits in dist, and a docblock control also 0, while a string-literal control (`Flow definition body required`) hits `dist/index.js` and `dist/index.cjs`; the maps carry no `sourcesContent` | ADR-0087 disposition on the breaking changeset: `not-required (no-migration-prescription)`. No metadata changes shape and nothing an author wrote is renamed or removed. Its body carries the migration: - FROM `POST …/NAME/toggle` on a customer flow TO `PUT /api/v1/automation/NAME` with `status: 'obsolete'` or `'active'`; - in the SDK, FROM `client.automation.toggle(name, false)` TO `client.automation.update(name, { ...definition, status: 'obsolete' })`. ## Outside the claim's declared file surface: deviations, each for a named reason The claim declared `engine.ts` (`toggleFlow` only), a pin file, the three docs lanes and `.changeset/20726-*.md`. These files moved beyond it, one reason each: 1. **A private helper beside `toggleFlow`** (`refuseCustomerAuthoredToggle`) holds the refusal, on the pattern of `refuseEnableOntoDisabledSubflow`. `toggleFlow` is its only caller. 2. **Fixture triage (necessary to stay green).** Ten existing cases toggled a flow with no package envelope, only as a vehicle for toggle semantics. Their subject now ships from a package (`_packageId: 'crm'`): - `engine.test.ts` ×6; - `engine-residual-log-cause`, `flow-label-on-result`, `flow-terminal-messages` and `node-type-vocabulary-seal-warning` ×1 each. The two §7.3 non-packaged pins in `flow-activation-ledger.test.ts` pinned toggling a customer flow. They are re-spelled through its status: a customer subflow is switched off by its status, and a customer caller is refused by the door and armed by its status. 3. **`packages/services/service-automation/README.md` (published).** Its example registered a flow in process and then toggled it off, which is exactly the call this change refuses. It now switches that flow off through its status and shows the toggle on a packaged flow. 4. **A DELIBERATE CORRECTION of a pending release note, `.changeset/20678-subflow-disable-sequence.md`**, for confirmation on this PR. - That note lists "A flow the customer authored" under "Not refused". In the release that ships it, the switch refuses a customer-authored flow before that guard is asked. - The sentence is corrected in its own entry, per AGENTS.md. The customer-authored subflow half stays true, and stays. - `node scripts/check-empty-changeset.mjs` is red on it by design (its DELIBERATE CORRECTION class). ⛔ Do not restore it from base: that would republish the false sentence. 5. **The clone door's notice** (`packages/runtime/src/flow-clone.ts`, `FLOW_CLONE_NOTICE`, plus its pin in `automation-flow-clone.test.ts` and a runtime `patch` changeset). - Every successful clone told the admin to switch the clone off through the toggle. A clone carries no package envelope, so the toggle refuses it. - Measured at the dispatcher seam with the real engine: the clone door answered 200 with that notice, the clone carried no `_packageId`, and the named toggle answered `RESOURCE_CONFLICT` / 409. - The notice now names the clone's own switch (its status, through `PUT`), and says the toggle switches packaged flows only and refuses the clone, whatever the clone was copied from: the clone door takes any registered flow as its source. - The old pin asserted the notice contains `toggle`, which pinned the prescription. It now asserts `status: 'obsolete'` and `PUT /api/v1/automation/` (red against the old notice, ablation M5). 6. **`IAutomationService.toggleFlow`'s docblock** (`packages/spec/src/contracts/automation-service.ts`, published in `dist/contracts/*.d.ts`) read "Enable or disable a flow", the same line as the API page. It now says the same as the other lanes. Prose only. ## Local verification at `d7eb865aa` Every reading below was taken on this tree at `d7eb865aa`, the head this PR opens with. The exit code was captured before any pipe. **Package suites.** Each package this diff touches got its test and typecheck (`pnpm --filter PKG`): | package | test | typecheck | |:--|:--|:--| | `service-automation` | `Test Files 157 passed (157)`, `Tests 1974 passed (1974)`; base `0d9349fea` read 156 / 1964 | exit 0; `--listFiles` counts the new pin file once in `tsconfig.json` and once in `tsconfig.test.json` | | `spec` | `576 passed (576)`, `16991 passed, 1 todo` | exit 0 | | `client` | `50 passed (50)`, `641 passed (641)` | exit 0 | | `runtime` | `290 passed (290)`, `4200 passed, 1 skipped` | exit 0 | | `dogfood` | the two toggle suites, `automation-toggle-tenant-scope` and `packaged-activation-ledger-reach`: `2 passed`, `19 passed` | | **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived on the real diff: 111 commands. All 111 ran, and `--ran` reads: "111 derived famil(ies) accounted for — 111 run, 0 NOT-MEASURED (a DERIVED zero — all 111 recorded an exit code and none of them is 3)". - 110 exit 0. - Three of those first exited 3 (PREREQUISITE NOT MET, no dist): `check:skill-examples`, `check:dual-build-cjs-loads` and `check:type-check-debt`. They exit 0 after a full packages build (`turbo run build`, 71/71 tasks). - **One exit 1, by design:** `node scripts/check-empty-changeset.mjs --base origin/main`. That is its DELIBERATE CORRECTION class on `.changeset/20678-subflow-disable-sequence.md` (deviation 4, for confirmation). **The seven roster families printed outside the runnable list**, each exit 0: - `node scripts/check-changeset-fixed.mjs` - `pnpm --filter @objectstack/spec run check:meta-url-spelling` - `pnpm --filter @objectstack/spec run check:spec-changes` - `pnpm check:authz-resolver` - `pnpm check:error-code-casing` ("no unlisted lowercase error codes in 7012 scanned file(s)") - `pnpm check:filter-alias-parity` - `pnpm check:route-ledger-census` **Extra**, each exit 0: - `pnpm check:durability-log-level` - `pnpm check:startup-registry-verdict` - `node scripts/check-changeset-no-major.mjs --base origin/main --event` with a synthetic `pull_request` payload carrying this body: "LEVEL AXIS: this PR declares clause-② `no (narrowing)`, and it grades a package whose `packages/**/src/**` it moves at `minor` or above" - `check-adr-0087-registration` with the same payload: 1 declared-breaking changeset, `not-required (no-migration-prescription)` **Generated artifacts.** `pnpm --filter @objectstack/spec check:generated`: all 15 up to date, after one `--fix` of `content/docs/references/**`. **Lint, a declared narrowing.** `eslint --no-inline-config --format json` over the 21 changed files: 21 files, 0 errors, 7 warnings. Every warning is "File ignored because no matching configuration was supplied", on the `.md` / `.mdx` files. - The population is `eslint.config.mjs`'s `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`. - The config never enables type-aware linting: it says so itself, with no `parserOptions.project` and 0 hits for `projectService`. So this diff cannot move a verdict on an untouched file. **Bytes.** `pnpm check:nul-bytes` exit 0, and a control-byte self-scan of the 21 files found nothing. **NOT MEASURED**, and why: - The six CI invocations whose argv comes from the workflow: `check-issue-citations --census`, three `check-shard-attestation --emit` and two `check-test-completeness`. - The dogfood suite beyond the two toggle files. The required `Dogfood Regression Gate` runs it. - CI on this PR, not awaited. ## Acceptance notes - **carrier: none. A boundary, measured at the engine seam: a customer flow that a ledger row already holds off stays off.** No status clears the row, and no door clears it now. The refusal names the clone step. Whether the door should clear such a row, or the consult points should ignore rows for flows without package provenance, is the dev report's open question 1. Noted, not filed. - **carrier: none. A boundary, read from source and not measured live: the named update door re-registers in process.** `PUT /automation/NAME` persists no definition. A flow whose definition lives on the metadata plane re-registers its stored definition at the next boot or metadata reload. The dev report's open question 2. Noted, not filed. - **carrier: none. Drift: the route ledger row for `POST /automation/:name/toggle` is out of date** (`packages/runtime/src/route-ledger.ts`). It still describes `toggleFlow` as writing "an in-process map keyed by flow name only", which has been untrue since the activation ledger landed, and it does not say "packaged only". Outside this PR's declared surface. Noted, not filed. - **carrier: none. Dead code: `refuseEnableOntoDisabledSubflow`'s early return for a non-packaged flow is now unreachable** through `toggleFlow`, its only caller. It is harmless, and left in place. Noted, not filed. - The 17.3 release notes' description of `client.automation.toggle` stays as published (`content/docs/releases/**` is release-owned). ## Patch round 1 (appended by the `domain:services` seat) - **Why:** the at-tier contract review on `d7eb865a` (record `5904799342`) FAILed on one clause. The clone-notice rider asserted that the clone's source is packaged ("such as the one it was copied from"), but `POST /:name/clone` takes any registered flow as its source. - **The fix:** `84334191`, one fast-forward commit (2 files, +5 / −4). The claim about the source is dropped from `FLOW_CLONE_NOTICE`, its docblock and `.changeset/20726-clone-notice-status-switch.md`. The prescription (the clone's own `status`, through `PUT /api/v1/automation/NAME`) is unchanged. - **Readings at `84334191`:** - the clone-notice pin file: 18 passed; - `@objectstack/runtime`: 4200 passed, 1 skipped; typecheck exit 0; - `dispatch-gates --commands`: the same 111 as round 1, all run. 110 exit 0 (`check:doc-authoring` among them), and the one exit 1 is `check-empty-changeset` on the confirmed DELIBERATE CORRECTION of the 20678 note, unchanged. - **The head moved,** so the at-tier record is re-taken on `84334191` before enqueue. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 80dc9c0 commit c8111a5

21 files changed

Lines changed: 504 additions & 39 deletions

‎.changeset/20678-subflow-disable-sequence.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Clause-②: no (narrowing)
1818
**Not refused:**
1919

2020
- Enabling a flow that is already enabled. Nothing is re-armed.
21-
- A flow the customer authored, or a subflow the customer authored.
21+
- 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").
2222
- 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.
2323

2424
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".
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): the clone door's notice names the clone's own off-switch, its status (#20726)
6+
7+
`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.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/client': patch
3+
---
4+
5+
docs(client): `automation.toggle` says it switches packaged flows only, and names a customer flow's switch (#20726)
6+
7+
`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.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the Automation API docblock says the toggle door switches packaged flows only, and names a customer flow's switch (#20726)
6+
7+
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.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
fix(service-automation)!: the toggle door refuses a flow no package ships, naming its status switch (#20726)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What moves is which flows the activation switch accepts: a flow without package provenance is refused, and its own `status`, which it always had, is its switch. -->
10+
11+
**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.
12+
13+
**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:
14+
15+
- 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.
16+
- 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`.
17+
- With no ledger attached, the flip was accepted in process only.
18+
19+
**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.
20+
21+
**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' })`.
22+
23+
**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.

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,22 @@ the former `GET /api/v1/automation` list route, its request/response schemas
2525
and `client.automation.list` are retired (ADR-0087 semantic entry
2626
`automation-flow-list-route-retired`).
2727

28+
The toggle door switches PACKAGED flows only: a flow a code package ships.
29+
It records the installation's choice in the packaged-metadata activation
30+
ledger (ADR-0126 §7.2). A flow authored in the deployment is not switched
31+
there. Its switch is its own `status`: `'obsolete'` disarms it and
32+
`'active'` arms it, published with the complete definition through
33+
`PUT /api/v1/automation/:name`. The toggle door refuses such a flow with
34+
409 `RESOURCE_CONFLICT`, names that switch, and changes nothing.
35+
2836
**Endpoints**
2937
```
3038
GET /api/v1/automation/:name — Get flow
3139
POST /api/v1/automation — Create flow
3240
PUT /api/v1/automation/:name — Update flow
3341
DELETE /api/v1/automation/:name — Delete flow
3442
POST /api/v1/automation/:name/trigger — Trigger flow execution
35-
POST /api/v1/automation/:name/toggle — Enable/disable flow
43+
POST /api/v1/automation/:name/toggle — Enable/disable a packaged flow
3644
GET /api/v1/automation/:name/runs — List execution runs
3745
GET /api/v1/automation/:name/runs/:runId — Get single execution run
3846
```

‎packages/client/src/index.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5483,9 +5483,6 @@ export class ObjectStackClient {
54835483
return this.unwrapResponse(res);
54845484
},
54855485

5486-
/**
5487-
* Enable or disable a flow
5488-
*/
54895486
/* [#3563 PR-5] The three descriptor/status routes that had no SDK
54905487
* expression — they back the Studio designer's pickers and badges. */
54915488

@@ -5525,6 +5522,17 @@ export class ObjectStackClient {
55255522
return this.unwrapResponse(res);
55265523
},
55275524

5525+
/**
5526+
* Enable or disable a PACKAGED flow — one a code package ships.
5527+
*
5528+
* [#20726, ADR-0126 §7.2] `POST /automation/:name/toggle` records the
5529+
* installation's choice in the packaged-metadata activation ledger, so
5530+
* it switches packaged flows only. A flow authored in the deployment is
5531+
* refused with 409 `RESOURCE_CONFLICT` and nothing changes. Its switch
5532+
* is its own `status`: send its complete definition through
5533+
* `automation.update(name, definition)` (`PUT /automation/:name`) with
5534+
* `status: 'obsolete'` to disarm it, or `status: 'active'` to arm it.
5535+
*/
55285536
toggle: async (name: string, enabled: boolean): Promise<{ name: string; enabled: boolean }> => {
55295537
const route = this.getRoute('automation');
55305538
const res = await this.fetch(`${this.baseUrl}${route}/${name}/toggle`, {

‎packages/runtime/src/domains/automation-flow-clone.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,11 @@ describe('#12156 — the source, the notice, and the gate', () => {
456456
// `obsolete`/`invalid`, so a cloned record-change flow runs beside the
457457
// one it was copied from. Saying so is cheaper than the surprise.
458458
expect(notice).toMatch(/off-switch/i);
459-
expect(notice).toContain('toggle');
459+
// [#20726] And the off-switch it names is one the clone has: its own
460+
// `status`, through its update door. A clone carries no package
461+
// envelope, and the activation toggle switches packaged flows only.
462+
expect(notice).toContain("status: 'obsolete'");
463+
expect(notice).toContain('PUT /api/v1/automation/');
460464
});
461465

462466
it('is an authoring write — a caller without `manage_metadata` is refused 403, nothing registered', async () => {

‎packages/runtime/src/domains/automation.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,10 @@ function isFlowEnablementWrite(parts: string[], method: string): boolean {
412412
* `POST /` → registerFlow (create)
413413
* `PUT /:name` → registerFlow (update)
414414
* `DELETE /:name` → unregisterFlow (deregister)
415-
* `POST /:name/toggle` → toggleFlow (enablement — commit 266436a7f, see below)
415+
* `POST /:name/toggle` → toggleFlow (enablement — commit 266436a7f, see below;
416+
* PACKAGED flows only, #20726: a customer
417+
* flow's switch is its `status`, via
418+
* `PUT /:name`)
416419
*
417420
* ## [commit 266436a7f] Why `toggle` joins them — ruled, not inferred
418421
*
@@ -1904,7 +1907,12 @@ export async function classifyResumeResult(
19041907
* ran and failed → 400 `FLOW_FAILED`; #9378 + #9415;
19051908
* a run that PAUSED → 200 with `runId` / `screen`,
19061909
* on whichever attempt it paused — #9510)
1907-
* POST /:name/toggle → toggleFlow (unknown name → 404, #7535)
1910+
* POST /:name/toggle → toggleFlow (unknown name → 404, #7535). Switches
1911+
* PACKAGED flows only — it writes the ADR-0126 §7.2
1912+
* activation ledger. A flow no package ships → 409
1913+
* `RESOURCE_CONFLICT` naming that flow's own switch,
1914+
* its `status` ('obsolete' / 'active') published
1915+
* through `PUT /:name`; nothing changes (#20726)
19081916
* ⚑ authoring write — `manage_metadata` (commit 266436a7f):
19091917
* enablement is environment-wide, so an
19101918
* unentitled toggle reached every organization

‎packages/runtime/src/flow-clone.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,24 @@ export const FLOW_CLONE_DROPPED_KEYS: readonly string[] = Object.freeze([
147147
* record-change flow and walks away has two flows running on one trigger,
148148
* and the only thing standing between them and that surprise is this
149149
* sentence.
150+
*
151+
* [#20726, ADR-0126 §7.2] The off-switch it names is the CLONE's own: its
152+
* `status`, published through `PUT /:name`. A clone carries no package
153+
* envelope (see {@link FLOW_CLONE_DROPPED_KEYS}), so it is a flow authored in
154+
* this deployment, and the activation toggle — which switches packaged flows
155+
* only — refuses it. That holds whatever the clone was copied from: the clone
156+
* door takes any registered flow as its source, packaged or not, so the
157+
* notice makes no claim about the source's provenance.
150158
*/
151159
export const FLOW_CLONE_NOTICE =
152160
'References are not re-pointed: this clone calls exactly what the original called '
153161
+ '(subflows, actions and objects are unchanged). It is created with status `draft`, '
154162
+ 'which is a lifecycle label and NOT an off-switch — a cloned record-change or schedule '
155163
+ 'flow is bound to its trigger and will run alongside the flow it was copied from. '
156-
+ 'Disable it (`POST /api/v1/automation/<name>/toggle` with `{"enabled": false}`) if that '
157-
+ 'is not what you want.';
164+
+ 'If that is not what you want, switch the clone off through its own status: send its '
165+
+ 'complete definition with `status: \'obsolete\'` to `PUT /api/v1/automation/<name>`. '
166+
+ 'The activation toggle (`POST /api/v1/automation/<name>/toggle`) switches packaged flows '
167+
+ 'only and refuses the clone.';
158168

159169
/** ADR-0112 envelope for the same-name refusal: a status AND a code. */
160170
export const FLOW_CLONE_NAME_TAKEN_STATUS = 409;

0 commit comments

Comments
 (0)