Workflow merge semantics
This is the most important page to read before you edit a workflow that's already live. It explains what cotctl workflows apply does with the fields you didn't write — and why an innocent-looking bots: [] can silently wipe automation that someone built in the web builder.
The contract, in one paragraph
Since cotctl 0.7.0, updating an existing workflow uses GET-merge-PUT: cotctl fetches the current state from the server, merges your YAML on top of it, and sends the result. The consequence is the rule you must internalize — a field you omit is preserved from the server; it is not wiped. Your YAML is a patch, not a replacement. (Since 0.14.0 the same rule holds for every kind, not only workflows — see What an update sends.)
And since 0.14.0, each request carries only what differs from the server: the Group, the TaskGroup, each state machine and each state is skipped on its own when it would change nothing, so re-applying a YAML that changes nothing sends no request at all and the workflow is reported unchanged.
Three intents, three shapes
For any mergeable field, the shape you write encodes your intent:
| What you write | What happens |
|---|---|
| Field omitted (no key) | Preserve — the server's current value is kept |
fieldName: [] (empty) | Delete — the server's value is replaced with empty. Destructive. |
fieldName: [ ... ] (values) | Replace — the server's value becomes your list |
| A sub-object with some keys | The keys you write replace; the keys you omit keep their server value |
The empty array is the trap. Omitting a field and setting it to [] look almost the same in YAML, but they mean opposite things: one says "leave it alone," the other says "clear it."
Before / after
Say the server has a transition with a bot configured in the web builder, and you apply this YAML to change the transition's canChange:
- target: po_approved
canChange: survey
requiredSurvey: survey_po_approval
# bots: not mentioned
Result: the bot is preserved. You only changed what you declared.
Now say you apply this instead:
- target: po_approved
canChange: manual
bots: []
Result: the bot is deleted. The empty array is an explicit instruction to clear the slot.
Which fields this applies to
- Workflow (Group):
nameDisplay,nameTranslations(a declared object keeps the languages it omits),color,icon,weight,isActive. - TaskGroup: the five permission arrays,
hideClosedAfterDays,availableViews,defaultView,defaultSelectedTaskTab. A declared permission list replaces the stored one, and when it leaves out entries the TaskGroup has,applynames them on stderr, list by list. - Each level is applied by presence. The Group is patched only when your YAML declares one of its fields, and the TaskGroup only when it declares one of its own — so a YAML that sets only
nameDisplaysends no TaskGroup update, and the reverse. A YAML that declares no root field at all runs in SM-only mode. - State machine:
name,description,isActive; a declaredassetis completed from the stored one (itspropertyonly while the asset keeps itspropertyType);cardLabelsandallowedExtensionsas described on the Workflows page. - State machine —
requiredSurvey: when you omit the whole block, the server's StartForm (survey, bots, permissions) is left fully intact. If you write a partial block, each sub-field follows YAML-wins-else-preserve. - State —
subtask: itsbotsandtargetpreserve unless you declare them. - State —
surveyTriggers[]: omitting the key preserves the whole list and[]deletes it. A declared list replaces the stored one — a stored trigger it leaves out is removed — but each entry you declare is paired with the stored trigger for the same survey and completed from it: an entry that omitsbotskeeps the bots stored for that survey (before 0.14.0 it wiped them). Writebots: []to empty them on purpose. - Transitions —
next[]: each transition is matched to the server by its resolvedtarget. For a matched transition, itsbots,requiredSurvey,permissionsand — since 0.14.0 —canChangepreserve unless declared. A transition whosetargetdoesn't match any existing one is new, and starts asmanual.next: []deletes every transition of the state — since 0.14.0; it used to be ignored — and a transition a declarednextleaves out is removed. - Bots, in every slot above, follow the omit/
[]/list rule. A declared bot is completed from the stored one when the slot holds one bot: the keys it omits keep their stored value, and each stage is paired with the stored stage of the samekeywhile it keeps its bot type (itsdataandnexttravel as written, and an omittedversionkeeps the stored one). When the slot stores several bots there is nothing to pair with, so the declared bot replaces all of them andapplywarns which stored keys it loses;workflows exportleaves such a slot'sbotsout, so re-applying the export keeps them.
Editing one element: partial: true
Without a marker, a declared list is the complete list — a declared states must name every stored state, and a declared next or surveyTriggers replaces the stored one. Since 0.14.0, a document with partial: true names only what it edits. To change one transition, name the workflow, its state machine, the state and that transition:
kind: Workflow
nameCode: purchase_orders
partial: true
stateMachines:
- code: po_flow
states:
- property: po_draft
next:
- target: po_approved
canChange: survey
requiredSurvey: survey_po_approval
- State machines are matched by
code, states byproperty, transitions bytarget, survey triggers bysurvey, and the stages of a slot's bot bykey. What you leave out stays as stored — states you omit are kept instead of refused — and a named element may leave out what the schema otherwise requires (a state machine'sname,propertyTypeandasset, a state'stype). - A named stage's
dataandnextmerge key by key:next: { ERROR: notify }reroutes one branch and keeps the others. next: []andsurveyTriggers: []name no element, so under the marker they remove nothing.bots: []is the exception: it still empties a slot, deleting the bot stored there with its stages, and the dry run and the apply warn about it. Leavebotsout to keep it.- A slot whose stored slot holds several bots is refused when the document declares its bot — bots carry no key to pair by.
- A deactivated state machine the document names is refused with exit
2, as without the marker.
The marker never creates the workflow, and --legacy-replace-workflows refuses it. The rules it shares with the other kinds are in apply → Partial documents.
The silent errors it prevents (and the ones to still watch)
The merge exists because pre-0.7.0 cotctl emitted near-complete bodies with hardcoded [] defaults, which silently wiped UI-managed config. That class of bug is gone for cotctl. Two things still deserve care:
- A stray
[].bots: []andnext: []are the destructive shapes you can still type by accident. When in doubt,cotctl workflows export <nameCode>first and see what the slot actually holds before you touch it. canChangevalues the YAML can't express. The schema only allowsmanual,survey,none, but the backend also accepts the legacytask-uiand*. A YAML that declarescanChangeon such a transition replaces it. OmitcanChangethere to keep it — which is whatworkflows exportwrites for those transitions since 0.14.0.
The merge is cotctl-side only. It protects cotctl applies. A PATCH sent directly (webclient, MCP, curl) still hits the backend's wholesale-replace behavior. The merge is a cotctl feature, not a change to the API.
The escape hatch
If you genuinely want the old destructive behavior — omitted fields wiped on the server — there's a flag:
cotctl workflows apply -f workflow.yaml --legacy-replace-workflows
It builds every level without the server's existing state, filling in the create defaults, so anything you didn't write is cleared. Since 0.14.0 its --dry-run shows that write: a permission list it would empty is a DANGER finding, so --fail-on-destructive exits 2 on it. It prints a warning and applies only to workflows. It was announced for removal in 0.8.0, which did not happen, and no removal version is announced now — treat it as deprecated and declare the fields you want to keep instead. You almost never want it.
See also
- Workflows — the landing page and root fields
- Immutability & versioning — the other class of apply surprises
- apply — the shared apply pipeline