The migration report cannot audit the migration, and an audit that cannot see reports clean
Mihai Perdum
Author
8 min readSeptember 9, 2026
Key takeaways
A migration report is generated by the process that did the migrating, from the same assumptions. It is the one source that cannot independently confirm anything.
A count of zero is the most dangerous number in a migration. Permission schemes and issue security levels make 'I cannot see it' look exactly like 'it is not missing'.
Prove your credentials can see that project's issues at all BEFORE you trust a zero from it — on that project, not a different one. Filters are usually per-object.
An audit that could write is an audit whose findings you have to second-guess. Read-only is the design, not a limitation.
Two custom fields with the same name are not the same field. Flag the collision on the row and let a human decide rather than letting a matcher guess silently.
Every migration ends with a report, and the report says it went well. That is not evidence, and it took me an expensive lesson to internalise why.
The report is generated by the process that did the work, from the same assumptions the work was done under. If the migration believed a project was empty, the report says the project was migrated and nothing was lost. If it believed a field mapped, the report says the field mapped. It cannot tell you about anything it did not know it should have been looking for, because it is the same piece of software with the same blind spots, marking its own homework.
So you go and check independently. Which is right, and introduces a worse failure than the one it was meant to catch.
The zero that looks like good news
Here is the shape of it. You run a comparison — issues on Data Center, issues on Cloud, which keys exist on one and not the other. A project comes back with nothing missing. You move on.
But the query ran as you. If your account cannot see that project's issues — a permission scheme that does not grant you Browse Projects, an issue security level that filters them, a role you were never added to — then the search returns an empty set. The comparison finds nothing on the source side to be missing on the target. The report says zero.
Zero missing and "I cannot see this project" are byte-identical from the outside. One is the best possible result and the other is a total blind spot, and nothing in the output distinguishes them.
This is not hypothetical for me. I once acted on a gate that told me a project was empty, when the truth was that my account could not see that project's issues. The number was correct — the query really did return nothing. The conclusion I drew from it was completely wrong, and the cost was not mine to pay.
The rule I now apply everywhere, and the one I would most like you to take from this article:
Before you believe any zero, prove the query can see the thing at all — on that same object. Not on a different project. A positive control somewhere else proves nothing, because the filters that hide things are almost always per-object. A permission scheme applies to a project. An issue security level applies to those issues. Demonstrating that you can read project A tells you precisely nothing about project B.
Practically, that means running a query you know should return results on the project in question, first. If you cannot make it return anything, you have not audited that project — you have failed to audit it, which is a different result and should be recorded as one.
Why the audit is read-only on purpose
The three audit tools we built for this do not write to Jira. The audits — the missing-issues scan, the field report, the config comparator — never send a request that changes anything on either instance; each reads two instances and produces a report you can hand over, two of them as a workbook and the comparator as a plain text file. The repository also ships an import pipeline for what the audit finds, and two of those scripts do write: one creates and deletes a probe issue to discover required fields, one adds a backfill label. Both are separate commands and need an explicit --apply to run in full; the probe does nothing without it, and the label script writes to a single issue as a test before you turn it loose. The audit itself never touches either instance.
That constraint looks like a limitation and is actually the point. An audit that could write is an audit whose findings you have to second-guess. Did the number change because the migration was incomplete, or because a previous run of the audit touched something? Once a verification tool can mutate, its output stops being evidence and becomes another thing to verify. Keeping the write path out entirely means the answer to "could this tool have caused what it is reporting?" is structurally no, rather than probably not.
It also means you can hand it to a client and run it against production without a change-approval conversation, which is not nothing.
Three questions worth asking from outside
The questions an independent audit should answer are the ones the migration cannot answer about itself.
Which issues exist on Data Center and not on Cloud? A key-by-key comparison across both instances, one tab per affected project. The wrinkle is that some issues legitimately arrive under a new key — recreated rather than moved — so a naive key comparison flags them as missing. Handling that remapping is the difference between a report a client can act on and a list padded with false alarms that gets ignored, which is the worse outcome because it discredits the real entries alongside the noise.
Which Data Center custom fields have no Cloud counterpart, and why? Matched by name, emitted as two sheets: what mapped, and what did not with a reason for each. Run it before the migration to predict what will not map, and again after to confirm what did not. The before-run is the one people skip, and it is the one that lets you decide rather than discover.
There is a trap in name-matching worth naming. Two fields called Segment — one of them a (migrated)-suffixed duplicate created during a prior move — are not the same field. A matcher that resolves that silently will pick one, and it may pick the empty one, and the report will say the field mapped fine. Our field report still maps to the first match, but it flags the row with how many Cloud fields share that name, so the ambiguity is visible and a human decides. The tool's job is to surface the ambiguity, not to resolve it quietly. This is the same failure I have written about before, where a wrong field id counts to zero rather than erroring — the machinery is perfectly happy, and only a person notices.
Do the two instances actually agree, or only look like they do? Issue types, issue type schemes, link types, priority schemes, priorities, hierarchy, sprint and board configuration, time-tracking settings. Configuration drift does not break anything on cutover weekend. It breaks something later, when a rule fires against a hierarchy level that exists on one side and not the other, and by then nobody connects it to the migration.
Freeze the source, or your list is fiction
One operational point that costs people a day if they learn it the hard way.
If Data Center is still live while you audit, every checkpoint is stale the moment it is written. Issues get created after your scan and legitimately do not exist on Cloud, so they appear as "missing" and your list grows for reasons that have nothing to do with the migration. You then spend an afternoon investigating gaps that are simply new work.
Lock the source read-only first, or discard old checkpoints and scan fresh. Do not try to reason about the difference — you cannot, because the two causes look identical in the output. This is the same discipline as stamping a plan with the tenant pair it was built for: the audit is only valid for the state it was taken against.
Expect the Cloud side to set the pace. Atlassian's docs say only that /search/jql "may return fewer" items per page than requested when a large number of fields or properties are asked for. In my runs it has handed back about 100 issues a page whatever the code asked for, even with a single field requested. On a large corpus the paging, rather than the network, is what determines how long a scan takes. Plan for the wall-clock rather than trying to tune around it.
What "verified" actually costs
The uncomfortable part of all this is that real verification is more expensive than the thing it verifies, and it never produces a satisfying artefact. A successful audit is a spreadsheet full of zeroes that you had to work hard to trust.
But the alternative is what the migration report already gave you: a green tick generated by the thing being checked. That is not verification, it is self-assessment, and the reason it is so seductive is that it arrives free and on time and says what everyone wants to hear.
If you take one habit from this: treat every zero as a claim that needs its own evidence. Not a result — a claim. Ask what would have to be true for that zero to be wrong, then go and check that specific thing, on that specific object. It is a slower way to work and it is the only one that has ever caught anything for me.
The three tools are open source and free — the Migration Audit Toolkit, Apache-2.0, and the audits themselves never write. They exist because "did everything arrive?" deserved an answer from somewhere other than the process that did the arriving.
Four ACPs, a gruelling two months, and an Atlassian Certified Expert badge at the end of it. The gamble was harvesting every dump site on the internet and refusing to believe a single answer until the documentation said so — and the free practice exams that came out of it now cover all five ACPs.