Your migrated workflow rules still run. That is not the same as still working
Gabriela Perdum
Author
11 min readSeptember 7, 2026
Key takeaways
Missing data leaves a hole you can see. Broken logic leaves a rule that is present, enabled and wrong — there is nothing to notice.
Measured: a JSU condition of priority > 5 persisted on Cloud as priority = 5, because the target rule only accepted = and !=. The comparator was silently demoted.
DC's REST API does not return workflow rule bodies. /rest/api/2/workflow gives summaries; workflowDesigner gives rule COUNTS. You can count the rules without being able to read them.
Run order is not cosmetic: filters, then workflows, then app rules inside those workflows, then automation last — automation depends on the fields and workflows already existing.
Verify by behaviour, not by presence. A rule that appears in the Cloud UI has told you nothing about whether it still does its job.
There is a particular kind of migration defect that nobody catches during cutover weekend, and it is not the kind everyone prepares for.
When issues fail to arrive, you find out. A project is short four hundred issues, a custom field is blank across the board, an attachment link 404s. There is a hole, someone falls into it, and it gets raised. Unpleasant, but self-reporting.
The logic around those issues is different. A workflow validator that no longer validates does not throw. It does not appear in a report. It sits in the transition exactly where you left it, enabled, correctly named, and it waves through the thing it was written to stop. You find out weeks later, when someone asks why a ticket reached Done without an approval, and the answer is that it has been doing that since the migration.
The one that convinced me to write this down
Here is a real case, and it is the most benign-looking bug I have seen in a migration.
A workflow-extensions condition on Data Center checked priority > 5. Ordinary stuff — block the transition unless the priority is above a threshold. It migrated. It arrived on Cloud as a native rule, present and enabled in the transition, with the same field and the same value.
It arrived as priority = 5.
The Cloud rule it was translated into only emitted two comparators, = and !=. Anything else collapsed into the nearest one it could express. So a greater-than became an equals, and the condition went from "priority is above five" to "priority is exactly five" — which is false almost always, in a rule whose whole job was to be true most of the time.
Nothing errored. The migration report counted a workflow moved. The rule appears in the Cloud UI with a sensible description. An admin reviewing the transition sees a condition on priority and moves on, because it looks like the condition that was always there.
That is the shape of this entire category. The rule is present. The rule is wrong. There is no signal.
The fix in our own tooling was to translate the full set — six comparators (>, >=, =, <=, <, !=) across five comparison types (STRING, NUMBER, DATE, DATE_WITHOUT_TIME, OPTIONID), taken from the source app's own constants rather than guessed. And an honest caveat that survived the fix: for STRING and OPTIONID, Cloud genuinely does not support ordered comparison, so > and < still demote to != and =. That case cannot be repaired, only reported, which is the difference between a tool you can trust and one you cannot.
You can count the rules. You cannot read them
Before you can check any of this, you have to get the rules out of Data Center, and this is where the ground gives way.
GET /rest/api/2/workflow returns summaries. Name, description, steps. Not the transition rule bodies — not the conditions, validators and post-functions that are the entire subject of this article. Those are not exposed by any documented REST endpoint.
There is an endpoint that gets tantalisingly close. /rest/workflowDesigner/1.0/workflows?name=X returns the workflow's layout and its rule counts. So you can learn that a transition has three validators. You cannot learn what any of them does.
The obvious fallback is the admin UI, which does show them. That is gated behind WebSudo — the re-enter-your-password prompt — and WebSudo cannot be passed programmatically when 2FA is enforced on the account, which on a well-run instance it is.
So the supported route is neither: export the workflows as OSWorkflow XML from the DC admin UI, and read the XML. That is not a workaround for a gap in the API. Given the API, the admin pages and the auth model, it is the only door that opens.
I want to dwell on the counts endpoint for a second, because it is the perfect miniature of the whole problem. It will happily tell you that the number of rules matches on both sides. Three before, three after. A tidy number, a green tick, and no information whatsoever about whether any of the three still works.
Three ways a rule survives the move and stops working
The comparator demotion above is one flavour. There are at least two more, and they fail differently enough that a single check will not catch all three.
The macro that becomes literal text. Workflow-extension apps use runtime macros — %%CURRENT_USER%% and friends — that get substituted at evaluation time. Cloud has no equivalent. Passed through naively, the rule persists with the macro as a string: a condition comparing a custom field against the literal characters %%CURRENT_USER%%. It is a valid rule. It saves without complaint. It is false forever, because no field ever contains that text. The right behaviour is to refuse the translation and tell the operator, which is what we ended up doing — a dedicated sheet in the review workbook listing every macro that has no Cloud equivalent, because a rule you were warned about is recoverable and a rule that silently compares against a string is not.
The expression that is always false. Cloud rules can carry Jira Expressions, and an expression referencing a property that does not exist does not error — it evaluates falsy. A condition checking group or role membership via user.roles looks entirely reasonable, and there is no such property on the Cloud user type. The expression is syntactically fine, the rule saves, and it denies everything. Getting this right meant reading Atlassian's own User type reference and rewriting to user.getProjectRoles(issue.project), and the reason it is worth naming here is that the failure mode of a wrong property is silence, not a stack trace.
There is a related trap in the same family: Jira Expressions does not permit dot-access to custom field ids, so issue.customfield_10100 is not valid where issue["customfield_10100"] is. I have not established whether that one errors or simply evaluates falsy like the others, so treat it as unknown rather than assuming it will announce itself.
The rule that is dropped and not counted as dropped. Some rules get rejected on apply as unrecognised. If your tooling classifies those as "not a real rule" rather than "a real rule I could not translate", they vanish from both the target and the report. We hit this with previous-status validators arriving from a prior migration — legitimate rules, silently discarded on every run, because the allowlist did not know their short name.
How many of these are there, really
The honest answer is that nobody can tell you a percentage, because the population depends entirely on which apps a given instance used and how heavily. What I can give you is one instance's number.
Comparing the exported DC workflow XML against what actually landed on Cloud, our own audit tool bucketed every discrepancy by root cause: field unresolved, catalogue miss, field unmapped, status unmapped, no mapper, and a residual MISSING_OTHER bucket for rules DC had and Cloud did not. On one migration that residual bucket alone held 90 entries — ninety rules present on Data Center and absent on Cloud, none of which had produced an error anywhere.
Two things to take from that number rather than the number itself.
The first is that it took a comparison to find them. Not a report, not a count, not an inspection of the Cloud side. You cannot find an absent rule by looking at what is present; you can only find it by holding the two sides against each other. That means keeping the DC export after cutover, which is the step people skip because the migration is "done".
The second is the shape of the bucketing. "Manual review: 90" is not actionable and gets deferred forever. Ninety split into macro / status / field / no-mapper, each in its own tab, is a morning's work with an obvious starting point. When we lumped status-reference failures in with generic field failures, the operator could not tell which rules failed for what reason, and the whole list went untouched. Categorising the failure is most of the fix.
The order is not cosmetic
Four things need moving, and they depend on each other in one direction only.
Filters go first. Everything downstream is verified using JQL, and verifying with JQL that itself references dead custom field ids or a DC filter id is how you conclude that a working thing is broken or, much worse, that a broken thing is fine. Saved filters copy body-for-body, so their JQL still names DC ids, DC asset fields, and functions Cloud does not have. The filter loads and quietly returns the wrong set.
Workflows next, so the transitions exist. Then the app-specific rules inside those workflows, which is the step this article is mostly about. Then automation last, because an automation rule references fields, statuses and workflows that must already exist for its own id mapping to resolve.
Automation carries its own distinct problem, which is the rule actor rather than the rule body — the account a rule runs as usually has no permissions on the target, and being site-admin is not sufficient. That has its own article, and it is worth reading before you touch automation, because it is the one failure in this set that does surface an error.
What "verified" has to mean here
If the defect has no symptom, then "I checked and it looked fine" is not a check. A few principles came out of building this that generalise past our own tooling.
Validate before mutate, against the platform's own validator. Jira exposes /rest/api/3/workflows/update/validation. Every write should go through it first and surface what it says rather than auto-correcting, because an auto-correction is exactly how a > becomes an = without anyone deciding that.
Re-fetch the live target before every apply. Fingerprint what is actually there, now, rather than trusting a plan built ten minutes ago. A stale plan that double-applies is its own silent corruption, and the mistake is easy: the plan is internally consistent, so it looks right.
Be additive, and be explicit about the one exception. Tooling should add and correct, not delete rules it did not recognise. Where deletion is genuinely correct — a rule referencing a custom field that does not exist on Cloud, and therefore already broken — it must be reported individually, not summarised as a count.
A plan is valid for exactly one source-target pair. Re-running an apply against a different Cloud target does not fail; it pushes custom field ids from the first tenant into the second. This is the same class of error as a migration plan stamped for one tenant pair and replayed against another, and it deserves the same fingerprint gate.
Produce a workbook, not a log. A run should emit a reviewable file with one tab per category needing a human — unmappable macros, unresolved statuses, unresolved fields, no-mapper — because "manual review" as a single bucket tells an operator nothing about where to start. And that workbook is a checklist, not a sign-off. A translated rule still deserves an expert eye in the Cloud UI.
Cloud to Cloud is not exempt
It is tempting to read all of this as a Data Center problem that goes away once you are on Cloud. It does not. Every entity id is scoped to its site — statuses, issue types, screens, events, roles, groups, priorities, resolutions, link types, security levels, custom fields. A workflow copied verbatim from one Cloud site to another is a workflow full of references to ids that mean something else, or nothing, on the target.
The difference is that Cloud-to-Cloud at least gives you REST on both ends, so the rules are readable without the XML detour. The failure modes underneath are the same: a rule that resolves to the wrong entity is still a rule that is present, enabled and wrong.
The other Cloud-to-Cloud specific is app rules carrying a Connect prefix that differs between the two sites, which leaves rules that look correct in the JSON and match nothing at runtime. Normalising those prefixes is its own step, and it is invisible unless you go looking.
What to go and check on Monday
If you have migrated in the last year and none of this was on your list, three checks will tell you most of what you need to know.
Take your five most business-critical transitions and open the conditions and validators in the Cloud UI. Not the count — the actual configuration. Compare each against what the DC workflow XML says it was. You are looking for comparators that changed, values that are now string literals, and rules that are simply absent.
Then take any saved filter that a dashboard or an automation depends on and check its JQL for cf[ references, customfield_ ids and filter ids. If it runs and returns results, that is not evidence of anything; the question is whether the results are the ones it used to return.
Then pick one workflow rule you know should block something, and try to do the thing it blocks. Behaviour is the only verification that survives this category of defect. Presence is not.
The four tools I have been describing are open source and free — the Jira Workflow & Automation Toolkit, Apache-2.0, public REST only. They came out of doing this work on real migrations, and they exist because the alternative was checking several hundred transitions by hand and still not being sure.
But the tools are the smaller half. The larger half is the habit: after a migration, a rule that is present has told you nothing. Go and make it refuse something.
A semantic hash makes a re-run cheap. Ours also erased a mention's accountId, so two different users hashed the same and a corrected mapping would be silently skipped. Here is the pattern, the bug, and the tests that would have caught it.