Notes from the work

One email when a tutorial or migration write-up goes live. Nothing else, and an unsubscribe link in every email.

LeanZero

Two people in Romania doing Atlassian migrations, Forge apps and practical AI work for teams that would rather talk to the person doing the job. Most of what we learn ends up on this site.

Services

  • Atlassian Migrations
  • Atlassian FastShift
  • Atlassian Maintenance
  • Forge App Development
  • AI Development Consultation

Topics

  • Jira
  • Jira Service Management
  • Confluence
  • Bitbucket
  • Atlassian Forge
  • Cloud Migration
  • Local AI
  • AI Coding
  • Certifications
  • Atlassian Team
  • All topics

Company

  • Blog
  • Tutorials
  • Contact

Community

  • Join Discord
  • Support this site

© 2026 LeanZero. All rights reserved.

Privacy PolicyTerms of Service24/7 SupportTrust CenterSecurityDPALegal notice
  1. Home
  2. Blog
  3. Migration Plan Wrong Tenant Fingerprint
The migration plan that runs against the wrong tenant, and the check that lets it
Cloud MigrationJira
Part 10 of 11 · Atlassian Migrations

The migration plan that runs against the wrong tenant, and the check that lets it

Mihai Perdum
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.

js
1function _normalize(u) {
2  return String(u || "").trim().toLowerCase().replace(/\/+$/, "");
3}
4
5function _fingerprint(dest, source) {
6  return crypto
7    .createHash("sha1")
8    .update(`dest:${dest}|source:${source}`)
9    .digest("hex")
10    .slice(0, 8);
11}

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:

js
1if (storedDest && curDest && storedDest !== curDest) { … }

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:

js
1if (storedSource && curSource && storedSource !== curSource) { … }

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
4
5verify() 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:

text
1  stored fingerprint correct  -> COMPATIBLE
2  stored fingerprint TAMPERED -> COMPATIBLE
3  fingerprint field REMOVED   -> COMPATIBLE

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:

text
1RIGHT source    driftRatio= 0   buckets= {"same":3}                  gate => proceed
2WRONG source    driftRatio= 1   buckets= {"missing-in-source":3}     gate => EXIT 2

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.

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.

ShareXLinkedIn
PreviousThe best 4-bit format in MLX is the one its own converter sets up to loseNextI did not think certifications were worth much. Then one of them showed me what I had been missing

Tags

#atlassian-migration#jira-cloud#dc-to-cloud#cloud-to-cloud#safety#tooling

Comments

0

Add a comment

Confirm your address using the email we send. You can unsubscribe at any time. Your email stays private and this does not sign you up for the newsletter.

Enjoyed?

Join our Discord community to discuss this article and get help with your own migration projects.

Discuss in Discord

On this page

  • What gets stamped
  • Where the two migration types diverge
  • The case where the check stands aside
  • The hash is not doing the work
  • What to do about it
  • The generalisable part

Join the Community

Join the community to ask questions, share your work, and connect with other professionals.

Join Discord

Found My Advice Helpful?

If I helped you solve something for free, coffee money is always appreciated (but never expected).

Buy Me a Coffee

Related reading

“This app might stop working soon”: what admins do before Connect end of support

Connect end of support begins on 31 January 2027. On our test site the Connected apps page tagged 4 of 57 entries Legacy, said January 2027 in its banner and December 2026 in its tooltip, and had no column or tag saying Connect or Forge. Here's how to find your affected apps and what to ask each vendor.

10/17/2026

Rovo usage limit: set or turn off extra usage before 3 December

On 16 October I switched on Rovo extra usage on our test org, set a 1,000-credit limit and turned it off again. The switch is an order on the company card, it starts with no limit at all, and the audit log list doesn't name the meter that changed. Billing for extra credits starts on 3 December 2026.

10/16/2026

Atlassian usage-based pricing: a 2027 budget for 50, 200 and 500 seats

We priced Atlassian's 2027 bill for 50, 200 and 500 seats: the 13 October 2026 list prices plus Rovo credits and automation steps over the allowance, using credit costs we measured on our own test org. Routine Rovo use adds $7,464 a year to a 200-seat Jira and Confluence Standard org. Teamwork Collection Standard costs less at all three sizes.

10/16/2026