The migration plan that runs against the wrong tenant, and the check that lets it
Mihai Perdum
Author
9 min readSeptember 2, 2026
Key takeaways
A plan carries cached ids, field mappings and validation responses for ONE (source, destination) pair. Replayed elsewhere it writes them to the wrong tenant.
compare() calls an unsigned plan incompatible; verify() warns and PROCEEDS. That covers a missing key, an empty object, a signature with a hash but no URLs, and the literal string "abc".
DC-to-Cloud stamps both URLs and blocks a mismatch on either. Cloud-to-Cloud legitimately has an empty source, so only the destination is checked.
An empty side can never mismatch: a Cloud-to-Cloud plan replayed in a run that has a DC source configured comes back COMPATIBLE.
The 8-char fingerprint never decides anything: zero conditionals in compare() branch on it. The decision is a normalised URL string comparison, and the hash is a label printed beside it.
A migration plan is not a script. It is a file full of decisions already made — this custom field maps to that one, this issue got that id, this validation already passed. All of those decisions are true of exactly one pair of sites: the source it was built from and the destination it was built for.
Re-run it against a different destination and none of that is true any more. The cached ids point at objects that do not exist there, the field mappings are for someone else's schema, and the audit at the end compares against the wrong issues. The comment at the top of our own guard puts the cost plainly: "a 1-minute mistake takes hours to roll back."
So there is a fingerprint. This is what it catches, what it does not, and the one case where it declines to act at all.
What gets stamped
At plan time the plan gets a signature: the two base URLs, normalised, plus a short hash of the pair.
Normalising matters more than it looks. https://acme.atlassian.net/ and https://ACME.atlassian.net are the same tenant and must not read as a mismatch, or operators learn to pass the override flag routinely — and a safety check people habitually bypass is worse than no check, because it produces a feeling of safety without the fact.
At sync time, before anything is written, the stored signature is compared against the current configuration.
Where the two migration types diverge
This is the part that is genuinely different between a Data Center move and a Cloud-to-Cloud move, and it is not cosmetic.
Data Center to Cloud has two real URLs. The plan is stamped with the DC base URL as source and the Cloud site as destination, and a mismatch on either is caught:
text
1 same source+dest COMPATIBLE
2 WRONG destination tenant BLOCKED (dest-baseurl-mismatch)
3 wrong source DC BLOCKED (source-baseurl-mismatch)
Cloud to Cloud often has no source URL at all. When the source data arrives through Atlassian's own product-data copy rather than through your scripts, there is nothing sensible to put in that field, and an empty source is explicitly permitted. So the signature carries a destination and an empty string:
text
1 stamped: {"fp":"270989cb","dest":"https://beta.atlassian.net","source":""}
2 same dest, still no source COMPATIBLE
3 WRONG destination tenant BLOCKED (dest-baseurl-mismatch)
The destination check still fires, and writing into the wrong site is the accident with consequences. But notice what the second type gives up: half the signature is empty, so half the check is inert.
And here is where I had to correct myself. I wrote, in an earlier draft, that the destination check is "the one that actually protects you". It has the identical hole. The comparison is the same shape on both sides:
An empty destination is exactly as inert as an empty source. And it does not take a hand-edit to produce one — a single mis-keyed option at stamp time is enough, because build() reads opts.destBaseUrl and a caller passing destBaseURL hands it undefined:
text
1stamped by a mis-keyed option: {"d":"","s":"https://jira.acme.example.com"}
2that plan vs a COMPLETELY OTHER tenant COMPATIBLE PROCEEDS
3 LOG : [instance-fingerprint] OK — plan and current config match (fp=6151f52c)
That is the precise accident the guard exists to prevent, clearing the gate and printing a green OK line on the way through. An unset CLOUD_BASE_URL at sync time does the same thing.
So the honest statement is narrower than the one I started with: the destination check protects you whenever both the plan and the run carry a destination URL. Neither side being empty is something the check can tell you about, because emptiness is how the source-less case is meant to work.
That has a consequence worth knowing before you rely on it:
text
1 same dest + a DC source now COMPATIBLE
A plan built Cloud-to-Cloud, replayed in a run that now has a DC source configured, reads as compatible. The comparison only fires when both sides have a value:
An empty stored side can never mismatch. That is deliberate — it is what makes the source-less Cloud-to-Cloud case work at all — but it means the source half of the fingerprint protects you only when both plans have one. On a Cloud-to-Cloud engagement, you are relying on the destination check alone.
The case where the check stands aside
Here is the one that changed how I read this guard.
There are two functions. compare() returns a verdict; verify() acts on it. For an unsigned plan they disagree:
text
1compare() says:
2 null -> INCOMPATIBLE
3 {} -> INCOMPATIBLE
45verify() does:
6 matching plan PROCEEDS
7 WRONG destination tenant THROWS
8 wrong dest + allowMismatch PROCEEDS
9 NO SIGNATURE on the plan PROCEEDS
10 hand-edited: empty signature PROCEEDS
compare() correctly reports that a plan with no signature is not compatible with anything. verify() logs a warning and carries on.
And it is broader than a missing key. I fed it several shapes a damaged plan might actually have:
text
1 null (key absent) INCOMPATIBLE PROCEEDS
2 {} (empty object) INCOMPATIBLE PROCEEDS
3 fingerprint only, no URLs INCOMPATIBLE PROCEEDS
4 a bare string "abc" INCOMPATIBLE PROCEEDS
5 URLs present, no fingerprint COMPATIBLE PROCEEDS
6 correct signature, wrong tenant INCOMPATIBLE THROWS
Read the third and fourth rows. A signature carrying a fingerprint hash but no URLs is waved through, and so is the literal string "abc". The gate is not "does this plan have a signature" — it is "does this signature yield a URL". Any single one is enough to leave the soft path, including the source-only shape that leaves the destination unchecked. Anything that fails to produce a URL pair lands in the soft path, however obviously corrupt it looks.
The reasoning is defensible and it is written down: a plan that pre-dates the check should not become unusable the day the check ships. Refusing to run against every plan built last month would push operators straight to the override flag, and then the flag becomes routine, and then it is not a guard at all.
But look at what the exemption covers. Three classes of plan arrive unsigned, and the one I reached for first is the weakest.
The obvious one is old plans, from before the check existed. The second is plans stamped by current tooling while a URL was unset or mis-keyed — the case above, which produces a signature that is present and useless.
The third is the one with evidence behind it. I had written "hand-edited", picturing someone in an editor — but opening a file to fix a typo preserves the keys you did not touch, so the signature survives. The real mechanism is our own rewriting utilities: a plan rewriter in this repo streams a fixed list of top-level keys and drops anything not on the list. Not a person in vim; a tool doing exactly what it was told.
Either way the exemption lands on the plans you know least about, and the check ends up strictest with the plan you just built and most permissive with the plan you half-remember.
The practical consequence: damage the signature in almost any way and the fingerprint check stands aside — and not always with a warning. Strip both URLs and you get the warning. Strip one and the check takes the silent-pass path and reports OK. Not because anyone bypassed anything — because absence is treated as innocence.
The hash is not doing the work
One more thing I only found by tampering with it. The eight-character fingerprint is computed at plan time, stored, and printed in every log line and error message. It is not used to decide anything:
Inside compare(), stored.fingerprint appears four times and every one is in a return payload. The number of conditionals that branch on it is zero — there are a couple of || fallbacks for display, and nothing that decides anything. The compatibility decision is a string comparison of the normalised URLs, start to finish.
There is a sharper version of this. The error message prints both the stored and the current hash, but the pass line prints only the recomputed one. Replay that source-less plan with a DC source configured and it prints match (fp=6151f52c) while the plan on disk stores 270989cb. The line asserts a match and shows you a different number than the plan holds, and nothing anywhere compares the two.
That is a defensible design — comparing URLs directly is what lets the error name both sites instead of showing you two hashes that differ — but it changes what the mechanism is. It is a base-URL equality check with a hash printed alongside for correlation. If you are reading a log and treating a matching fp= as evidence the plan is verified, you are reading a label, not a verdict.
What to do about it
Three things, in the order they are worth doing.
Treat "no signature" as a stop on any plan that has been touched. The soft path is right for plans that legitimately pre-date the check, and wrong for one that was rewritten this morning. My first instinct was to use the file's mtime, which is unsound — cp, unzip, restore-from-backup and any copy between machines all reset it, and copying between machines is exactly the handed-off-plan path. The plan already carries better signals: the plan manager writes createdAt and updatedAt into the JSON, and those survive being copied. A file whose mtime is newer than its own updatedAt was changed by something that is not the tooling.
Do not let the override become normal. Our own error message offers two ways out and marks the second (DANGEROUS):
text
1Two ways out:
2 1. (RECOMMENDED) Re-run the plan phase against the current configuration.
3 2. (DANGEROUS) Pass --allow-instance-mismatch — only if you intentionally
4 want to apply a plan from another tenant.
Labelling it DANGEROUS in the text is not the same as making it hard. If that flag appears in a runbook or a shell history more than once, the check has already failed even though it never let anything through.
On Cloud-to-Cloud, use preflight as the source check. The fingerprint's source half is structurally inert on a source-less plan, but the toolkit already has something that covers it: preflight re-fetches every plan entry from the configured source before anything is written, and gates on drift. Pointed at the wrong tenant, every entry buckets as missing-in-source and the run stops:
On a Data Center move preflight is staleness hygiene. On Cloud-to-Cloud it is the source check, and it is not optional. Cloud-to-Cloud gets a great deal for free — identity comes across without resolution work, because the accountId belongs to the Atlassian account rather than the tenant. This is one of the places it does not. That is enough for the accident that matters, and it is worth saying out loud rather than assuming a fingerprint means both ends are verified.
The generalisable part
A guard has three states, not two: it caught something, it cleared something, and it did not run. Most of the attention goes to the first two, and almost all of the risk sits in the third — because a guard that did not run looks exactly like a guard that passed.
Ours announces itself on the happy path, which I now think is the single most useful thing about it:
text
1[instance-fingerprint] OK — plan and current config match (fp=270989cb)
If that line is absent from a migration log, the check did not clear the plan — it never evaluated it. Grepping for the presence of a pass message is a better safety net than watching for a failure, and it costs nothing on any check you already have.
Two caveats on that, both of which I would rather state than let you discover. It catches the did-not-run case and nothing else: every scenario above prints the pass line, so a grep for OK cannot distinguish a verified plan from one that passed because a side was empty. And capture both streams — the OK line goes to log.log and the no-signature warning to log.warn, so a runbook doing node sync.js > run.log keeps the reassurance and throws away the warning. It is the same reasoning as the gap list you write before a migration starts: the thing that hurts is never the item you assessed and accepted, it is the one nobody put on the list.
[[takeaways]] A migration plan is only valid for the pair of sites it was built against, and the instance fingerprint exists to enforce that. On Data Center to Cloud it checks both URLs and blocks a mismatch on either. On Cloud to Cloud the source is legitimately empty, so only the destination is checked — and because an empty side can never mismatch, a Cloud-to-Cloud plan replayed with a DC source configured reads as compatible.
The gap worth closing is not in the comparison, which is sound. It is that an unsigned plan is waved through with a warning, so a signature damaged in almost any way stops protecting you — and some of that damage reports OK rather than warning.
If you take one habit from this, take the log line. A guard that passes should say so, and you should check that it said so.