The tooling appends '(migrated)' when it resolves a clash, so a name-keyed differ reports those as missing unless you normalise.
Different tools per path: JCMA for Jira and JSM, CCMA for Confluence, Data transfer for Cloud to Cloud.
Every Atlassian migration carries most of your data and leaves a remainder. The remainder is published, it differs per path, and almost nobody writes it down before starting.
This builds two artefacts from your own instance: a list of what the tool will not carry, and an id remap table that refuses to guess. The second one matters more than it sounds.
Note
Prerequisites
Node 18 or later. Verified on v24.15.0.
Read access to your source instance, and to the destination if you have one.
An empty directory. Everything here runs offline — you export an inventory and diff it locally, so nothing writes to a tenant.
Know which path you are on, because the tools differ: Jira/JSM Data Center → Cloud uses the Jira Cloud Migration Assistant, Confluence Data Center → Cloud uses the separate Confluence Cloud Migration Assistant, and Cloud → Cloud uses Data transfer (the docs still carry its old name, Copy product data).
About forty minutes, most of it reading the copied-data page properly.
The insight that makes this worth doing
The obvious version of this exercise is a set difference: list what the tool copies, subtract it from what you have, and the remainder is your build.
That is wrong, and it is wrong in the direction that hurts. Atlassian's page does not say yes or no per kind. It says things like custom fields are copied — "Text, Date, Number, Time, Labels, URL, Select list…" — while "Project picker, Affected services, Responders" are not. It says "Workflows and permission schemes (that are not linked to any project won't be migrated)".
Those are partials. A boolean set filters them out as done, and you find out at cutover. So the coverage model here is three-state: carried, partial, not carried — and partials appear in the output as work, with the caveat attached.
I got this wrong myself. An earlier version of this used a boolean set, and its own worked example listed 19 dashboards as a rebuild — dashboards are copied. The tool was confidently inventing work.
1
Encode the copied-data page as a three-state coverage map for your path.
2
Export an inventory of your source instance by kind, as JSON.
3
Compute the gap list, with partials reported separately from outright gaps.
4
Diff the tenant-scoped config on id, normalising the "(migrated)" suffix.
5
Prove the differ exits non-zero on an ambiguous name.
6
Assign an owner to every line, which is what turns a list into scope.
Step 1 — Encode the page as three states
Open the current copied-data page for your path and read it properly. Save this as gaps.mjs:
javascript
1exportconstCARRIED="carried";2exportconstPARTIAL="partial";3exportconstNOT_CARRIED="not";45exportconstCOVERAGE_C2C={6projects:CARRIED,spaces:CARRIED,users:CARRIED,groups:CARRIED,7screens:CARRIED,"request-types":CARRIED,queues:CARRIED,8"sla-calendars":CARRIED,"customer-organizations":CARRIED,9dashboards:CARRIED,boards:CARRIED,filters:CARRIED,1011"custom-fields":PARTIAL,// named types only; project picker/responders excluded12workflows:PARTIAL,// only when linked to a project13"permission-schemes":PARTIAL,// only when linked to a project14"knowledge-bases":PARTIAL,// the link copies; article content needs its own handling1516"automation-rules":NOT_CARRIED,17"global-permissions":NOT_CARRIED,18"app-data":NOT_CARRIED,19};
That map is Cloud → Cloud. On the DC path, build the equivalent from your assistant's own "what gets migrated" page — JCMA's for Jira and JSM, CCMA's for Confluence. The members are different; the method is identical.
How you know it worked: count the states and confirm none is empty.
You should see all three counts above zero. A zero in partial means you read the page as a yes/no table, which is the mistake this whole step exists to prevent — go back and look for the words "some", "only", and any bracketed exclusion.
Step 2 — Export a source inventory
You need counts by kind. An array of identifiers or a plain number both work; anything else is refused rather than silently miscounted.
Keep the kind names identical to your coverage map. The method is a lookup, and a spelling mismatch moves an item into the wrong bucket.
How you know it worked: find kinds your coverage map has never heard of.
bash
1node-e"import('./gaps.mjs').then(m=>{const inv=require('./inv.json');
2 console.log('unknown kinds:', Object.keys(inv).filter(k=>!(k in m.COVERAGE_C2C)));})"
You should see unknown kinds: []. Anything listed there is either a spelling mismatch or a kind you have not classified yet — both are your problem to fix now, because gapList treats unknown kinds as work and will inflate your plan.
Step 3 — Compute the gap list
javascript
1functioncountOf(kind, v){2if(Array.isArray(v))return v.length;3if(typeof v ==="number"&&Number.isFinite(v))return v;4thrownewTypeError(`inventory["${kind}"] is ${typeof v}; give an array or a number`);5}67exportfunctiongapList(inventory, coverage =COVERAGE_C2C){8const out =[];9for(const[kind, v]ofObject.entries(inventory)){10const n =countOf(kind, v);11if(n ===0)continue;12const state = coverage[kind];13if(state ===CARRIED)continue;14 out.push({ kind,count: n,state: state ||"unknown"});15}16return out.sort((a, b)=> b.count- a.count);17}
countOf throws rather than returning zero. An earlier version quietly returned an empty gap list when handed counts instead of arrays — no error, no warning, and a plan that said there was nothing to do.
How you know it worked: confirm it refuses input it cannot count.
You should see refused: inventory["projects"] is object; give an array or a number. If it prints NO THROW, the guard is missing and the tool will silently under-report.
Step 4 — Diff the config on id
Configuration is tenant-scoped: a priority named "P1" on each side is two objects with two ids. Anything you carry yourself — filters, boards, automation — references the id.
javascript
1exportconststripMigratedSuffix=(n)=>String(n).replace(/\s*\(migrated\)\s*$/i,"");23exportfunctionidRemap(kind, source, dest){4const dupes =newMap();5for(const d of dest){6const k =stripMigratedSuffix(d.name);7 dupes.set(k,(dupes.get(k)||0)+1);8}9const srcNames =newSet(source.map((s)=>stripMigratedSuffix(s.name)));10const ambiguous =[...dupes].filter(([n, c])=> c >1&& srcNames.has(n)).map(([n])=> n);1112const byName =newMap();13for(const d of dest) byName.set(stripMigratedSuffix(d.name), d);1415const remap =[], orphans =[], agreed =[];16for(const s of source){17const n =stripMigratedSuffix(s.name);18if(ambiguous.includes(n))continue;// refuse, do not guess19const d = byName.get(n);20if(!d){ orphans.push({name: s.name,id: s.id});continue;}21if(String(d.id)===String(s.id)) agreed.push(n);22else remap.push({name: s.name,from: s.id,to: d.id});23}24return{ kind, remap, orphans, agreed, ambiguous };25}
Two details that are not decoration.
The suffix. Migration tooling appends (migrated) when it resolves a name clash, so the destination ends up holding Custom field A and Custom field A (migrated). A differ matching raw names reports those as missing, which sends you creating objects that already exist. Normalise before matching.
Ambiguity never enters remap. If two destination objects share a name, that name is excluded from the translation table entirely. A poisoned row in a table you intend to run is worse than an absent one.
How you know it worked: confirm the suffix is normalised.
You should see orphans 0 remap 1. If it prints orphans 1, the normalisation is not running and every clash the tool resolved will look like a missing object.
Step 5 — Prove it exits non-zero on ambiguity
A differ that warns and returns success will be consumed by something that ignores warnings. Make it fail. Save this as run.mjs:
The report function is a printer — full source is in gaps.mjs at the end of this article's repo layout, and any formatting you prefer will do; what matters is that every remap row and every orphan is printed rather than truncated.
How you know it worked. Run it and check the exit code:
bash
1node run.mjs;echo"exit=$?"
Real output:
text
1NOT CARRIED — you build these:
2 47 automation-rules
3 11 global-permissions
4 6 app-data
56PARTIAL — copied with documented exclusions, still check every one:
7 34 custom-fields
8 8 workflows
910ID REMAP — identical names, different ids:
11 priorities: 1 to remap, 1 already agree, 1 missing, 0 AMBIGUOUS
12 "P1" 1 -> 10001
13 "Blocker" (3) MISSING at destination
14 link types: 1 to remap, 0 already agree, 0 missing, 1 AMBIGUOUS
15 "Blocks" 10000 -> 10100
16 "Relates" AMBIGUOUS — two destination objects share this name; REFUSED
1718FAILED: 1 ambiguous name(s). Resolve them before trusting this table.
19exit=1
Three things to read there. Dashboards are absent from the gap list, because they are carried — the tool is not inventing work. Custom fields and workflows appear under PARTIAL, which is the whole reason for the three-state model. And "Relates" produced no mapping at all, with a non-zero exit.
Now remove the duplicate: delete { id: "10102", name: "Relates" } from dstLink and run again. You should get 0 AMBIGUOUS, a Relates remap row, no FAILED line, and exit=0. A check that cannot switch off is not testing anything.
Step 6 — Assign an owner to every line
The output is counts. Scope is counts with a name against each.
Forty-seven automation rules is not a task. "Priya rebuilds the 12 rules touching the release workflow before cutover, the other 35 after" is. Every orphan is a decision too — create it at the destination, map it elsewhere, or accept the loss — and each has a different owner.
How you know it worked: hand it to somebody who was not in the room. If they can say what they are responsible for without asking a question, it is scope. If they ask "so what do I do about app data", it is still a list.
What differs per path
Jira/JSM Data Center → Cloud (JCMA). It carries more than people expect, including dashboards, cross-project boards and filters, filter subscriptions, and automation flows. The remainder is mostly small in-project detail: custom field language translations, workflow properties and triggers, project avatars, canned responses, mail handlers, issue collectors, board sub-filters — plus global permissions and general configuration. Identity dominates the plan here for a different reason: username and userKey do not exist in Cloud, so every user reference resolves to an accountId.
Confluence Data Center → Cloud (CCMA). A different tool with a different list — global settings and permissions, application links, personal drafts, custom emojis, user-created macros and audit logs are among the things it leaves. Do not reuse the Jira map here.
Cloud → Cloud (Data transfer). Found at admin.atlassian.com → Data management → Data transfer → Create copy plan. It runs same-org or cross-org and gives you a plan with statuses to watch. Identity is smaller than on the DC path but not zero: group membership, permission changes on copied groups, blocklisted groups, users in inactive directories, and anyone who is not the same Atlassian account at the destination all need handling.
[[takeaways]] You now have a gap list that distinguishes what you build from what arrives partially finished, and a remap table that refuses to guess when two destination objects share a name — proven by a run that exits 1 and a second run that exits 0 once the duplicate is gone.
What this does not do is tell you whether the migration is a good idea, size the rebuild, or catch anything the copied-data page fails to mention. Re-read that page close to cutover. Everything here depends on it, and it moves — an earlier draft of this article was built on a stale reading of it and confidently listed carried objects as work.