This is an automated email from the ASF dual-hosted git repository.

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 63b8f15f74 Improve metadata item naming consistency, fixes #5597 
(#8554)
63b8f15f74 is described below

commit 63b8f15f74628d3307f24903720a0f18709df527
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Sep 24 12:51:18 2026 +0200

    Improve metadata item naming consistency, fixes #5597 (#8554)
    
    * Improve metadata item naming consistency, fixes #5597
    
    * fix remarks
---
 AGENTS.md                                          |   8 +
 .../org/apache/hop/metadata/api/HopMetadata.java   |  11 +
 .../hop/metadata/api/MetadataRefactorUtil.java     |   4 +-
 .../metadata/serializer/BaseMetadataProvider.java  |  16 +
 .../serializer/json/JsonMetadataProvider.java      |  24 +-
 .../serializer/json/JsonMetadataSerializer.java    | 180 ++++--
 .../apache/hop/metadata/util/HopMetadataUtil.java  |  77 +++
 .../hop/metadata/serializer/MetadataGuiFlows.java  |  91 +++
 .../json/JsonMetadataLegacyKeysTest.java           | 631 +++++++++++++++++++++
 .../serializer/json/renamed/CaseRenamedType.java   |  36 +-
 .../serializer/json/renamed/RenamedType.java       |  36 +-
 .../serializer/json/renamed/TwiceRenamedType.java  |  36 +-
 .../multi/MultiMetadataLegacyKeysTest.java         | 283 +++++++++
 .../modules/ROOT/pages/metadata-plugins.adoc       |  44 +-
 .../ROOT/pages/metadata-types/rest-connection.adoc |   2 +-
 .../ROOT/pages/pipeline/transforms/rest.adoc       |   2 +-
 .../metadata/refactor/MetadataReferenceFinder.java |   2 +-
 .../SerializableMetadataLegacyKeysTest.java        | 192 +++++++
 .../apache/hop/spark/metadata/SparkCatalog.java    |   3 +-
 .../org/apache/hop/git/provider/GitConnection.java |   3 +-
 .../{gitconnection => git-connection}/github.json  |   0
 .../org/apache/hop/lint/LintMetadataSelection.java |   9 +-
 .../lint/HopMetadataFileLoaderLegacyKeysTest.java  | 111 ++++
 .../hop/mail/metadata/MailServerConnection.java    |   3 +-
 .../apache/hop/metadata/rest/RestConnection.java   |   3 +-
 .../org/apache/hop/vfs/s3/metadata/S3Meta.java     |   3 +-
 .../vfs/azure/metadatatype/AzureMetadataType.java  |   3 +-
 .../databricks/metadata/DatabricksConnection.java  |   3 +-
 .../metadata/DatabricksVfsConnection.java          |   3 +-
 .../gs/metadatatype/GoogleStorageMetadataType.java |   3 +-
 .../org/apache/hop/vfs/hdfs/metadata/HdfsMeta.java |   3 +-
 .../apache/hop/vfs/minio/metadata/MinioMeta.java   |   3 +-
 .../metadata/salesforce/SalesforceConnection.java  |   3 +-
 .../hop/vfs/webdav/metadata/WebDavConnection.java  |   3 +-
 .../transforms/metainput/MetadataInput.java        |   3 +-
 .../github-releases.json                           |   0
 .../slack-conversations.json                       |   0
 .../perspective/metadata/MetadataPerspective.java  |  76 ++-
 .../ui/hopgui/search/HopGuiMetadataSearchable.java |  12 +
 .../search/HopGuiMetadataSearchableTest.java       |  99 ++++
 40 files changed, 1868 insertions(+), 156 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index ea81fcfd0d..2fa4db5adc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -123,3 +123,11 @@ Values defined in resource bundles (resource files in 
`messages/messages\*.prope
 
 Apache Hop and related projects should use Lombok for all classes to avoid 
cluttering classes with boilerplate getter/setter methods.
 
+# Metadata type keys
+
+The `key` of `@HopMetadata` is the metadata plugin ID and the name of the 
folder its objects are stored in (`metadata/<key>/<name>.json`). It is also 
written into serialized metadata exports and matched against 
`disabledGuiElements`. Treat it as a public, persisted identifier.
+
+- **New metadata types:** the key is lower-case and dash-separated 
(kebab-case) and names what the type is, for example `mail-server-connection` 
or `pipeline-run-configuration`. Never use PascalCase or the Java class name. 
Check this on every new or changed `@HopMetadata` annotation, including in 
reviews.
+- **Renaming a key:** never change an existing key without moving the old one 
to `legacyKeys`, for example `key = "mail-server-connection", legacyKeys = 
{"MailServerConnection"}`. Objects in the old folder keep loading, and each one 
moves to the new folder the next time it is saved. Without `legacyKeys`, 
existing projects silently lose those objects.
+- **Code that works with type keys or metadata files** must go through 
`HopMetadataUtil.getAllKeys()` / `matchesKey()` or 
`JsonMetadataSerializer.findFilename()`, not `annotation.key()` alone. 
Otherwise objects still in a legacy folder are missed.
+- `rdbms` predates this convention and stays as it is.
diff --git a/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java 
b/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java
index d8bf843869..85ed151216 100644
--- a/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java
+++ b/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java
@@ -31,6 +31,17 @@ public @interface HopMetadata {
    */
   String key();
 
+  /**
+   * Keys this metadata type was known under before it was renamed, for 
example to bring it in line
+   * with the lower-case, dash-separated naming convention. Objects stored 
under a legacy key (a
+   * JSON sub-folder named after it, or a serialized metadata export) are 
still found and loaded.
+   * Saving an object always writes it under {@link #key()} and removes the 
legacy copy, so projects
+   * migrate one object at a time as they are edited.
+   *
+   * @return the previous keys of this metadata type, most recent first
+   */
+  String[] legacyKeys() default {};
+
   /**
    * The key for this hop metadata object. It will be translated into a 
sub-folder for the JSON
    * serializer
diff --git 
a/core/src/main/java/org/apache/hop/metadata/api/MetadataRefactorUtil.java 
b/core/src/main/java/org/apache/hop/metadata/api/MetadataRefactorUtil.java
index 67a6863de6..443599d24d 100644
--- a/core/src/main/java/org/apache/hop/metadata/api/MetadataRefactorUtil.java
+++ b/core/src/main/java/org/apache/hop/metadata/api/MetadataRefactorUtil.java
@@ -33,7 +33,7 @@ public final class MetadataRefactorUtil {
    * reference used in pipeline/workflow XML (e.g. RDBMS_CONNECTION for 
"rdbms").
    *
    * @param provider metadata provider (used to resolve the class for the key)
-   * @param metadataKey the metadata type key (e.g. "rdbms", "restconnection")
+   * @param metadataKey the metadata type key (e.g. "rdbms", "rest-connection")
    * @return the property type, or {@link HopMetadataPropertyType#NONE} if not 
declared or unknown
    */
   public static HopMetadataPropertyType getPropertyTypeForMetadataKey(
@@ -56,7 +56,7 @@ public final class MetadataRefactorUtil {
    * pipelines and workflows when renaming). Uses {@link 
HopMetadata#supportsGlobalReplace()}.
    *
    * @param provider metadata provider (used to resolve the class for the key)
-   * @param metadataKey the metadata type key (e.g. "rdbms", "restconnection")
+   * @param metadataKey the metadata type key (e.g. "rdbms", "rest-connection")
    * @return true if global replace is supported, false otherwise
    */
   public static boolean supportsGlobalReplace(IHopMetadataProvider provider, 
String metadataKey) {
diff --git 
a/core/src/main/java/org/apache/hop/metadata/serializer/BaseMetadataProvider.java
 
b/core/src/main/java/org/apache/hop/metadata/serializer/BaseMetadataProvider.java
index 6c1f2b43c9..553ebac0f6 100644
--- 
a/core/src/main/java/org/apache/hop/metadata/serializer/BaseMetadataProvider.java
+++ 
b/core/src/main/java/org/apache/hop/metadata/serializer/BaseMetadataProvider.java
@@ -25,8 +25,10 @@ import org.apache.hop.core.gui.plugin.GuiRegistry;
 import org.apache.hop.core.plugins.IPlugin;
 import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.IHopMetadata;
 import org.apache.hop.metadata.plugin.MetadataPluginType;
+import org.apache.hop.metadata.util.HopMetadataUtil;
 
 public class BaseMetadataProvider {
 
@@ -51,6 +53,12 @@ public class BaseMetadataProvider {
         }
         String className = plugin.getClassMap().get(plugin.getMainType());
         Class<?> pluginClass = 
registry.getClassLoader(plugin).loadClass(className);
+        // A type can still be disabled with the key it had before it was 
renamed.
+        HopMetadata annotation = pluginClass.getAnnotation(HopMetadata.class);
+        if (annotation != null
+            && 
HopMetadataUtil.getAllKeys(annotation).stream().anyMatch(disabledIds::contains))
 {
+          continue;
+        }
         classes.add((Class<T>) pluginClass);
       }
       return classes;
@@ -64,6 +72,14 @@ public class BaseMetadataProvider {
       PluginRegistry registry = PluginRegistry.getInstance();
       IPlugin plugin = registry.findPluginWithId(MetadataPluginType.class, 
key);
       if (plugin == null) {
+        // The key can be one a metadata type was known under before it was 
renamed, for example
+        // in a serialized metadata export written by an older version of Hop.
+        //
+        for (Class<T> metadataClass : this.<T>getMetadataClasses()) {
+          if 
(HopMetadataUtil.matchesKey(metadataClass.getAnnotation(HopMetadata.class), 
key)) {
+            return metadataClass;
+          }
+        }
         throw new HopException(
             "The metadata plugin for key " + key + " could not be found in the 
plugin registry");
       }
diff --git 
a/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataProvider.java
 
b/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataProvider.java
index b5c6895969..4a36ae57a1 100644
--- 
a/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataProvider.java
+++ 
b/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataProvider.java
@@ -17,6 +17,8 @@
 
 package org.apache.hop.metadata.serializer.json;
 
+import java.util.ArrayList;
+import java.util.List;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.core.Const;
@@ -32,6 +34,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.api.IHopMetadataSerializer;
 import org.apache.hop.metadata.serializer.BaseMetadataProvider;
+import org.apache.hop.metadata.util.HopMetadataUtil;
 
 @Getter
 @Setter
@@ -83,16 +86,25 @@ public class JsonMetadataProvider extends 
BaseMetadataProvider implements IHopMe
               + " it needs to have annotation "
               + HopMetadata.class.getName());
     }
-    String classFolder = Const.NVL(hopMetadata.key(), hopMetadata.name());
-    String serializerBaseFolderName =
-        baseFolder
-            + (baseFolder.endsWith(Const.FILE_SEPARATOR) ? "" : 
Const.FILE_SEPARATOR)
-            + classFolder;
+    // The current key names the folder to save in, legacy keys name folders 
we still read from.
+    //
+    List<String> keys = HopMetadataUtil.getAllKeys(hopMetadata);
+    String serializerBaseFolderName = calculateTypeFolder(keys.get(0));
+    List<String> legacyFolders = new ArrayList<>();
+    for (String legacyKey : keys.subList(1, keys.size())) {
+      legacyFolders.add(calculateTypeFolder(legacyKey));
+    }
 
     String description = TranslateUtil.translate(hopMetadata.name(), 
managedClass);
 
     return new JsonMetadataSerializer<>(
-        this, serializerBaseFolderName, managedClass, variables, description);
+        this, serializerBaseFolderName, legacyFolders, managedClass, 
variables, description);
+  }
+
+  private String calculateTypeFolder(String key) {
+    return baseFolder
+        + (baseFolder.endsWith(Const.FILE_SEPARATOR) ? "" : 
Const.FILE_SEPARATOR)
+        + key;
   }
 
   /**
diff --git 
a/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
 
b/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
index fde873cf1f..acb478c559 100644
--- 
a/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
+++ 
b/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
@@ -28,7 +28,9 @@ import java.io.OutputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Set;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.commons.lang3.StringUtils;
@@ -55,6 +57,13 @@ public class JsonMetadataSerializer<T extends IHopMetadata> 
implements IHopMetad
   @Setter @Getter protected Class<T> managedClass;
   @Setter @Getter protected String description;
 
+  /**
+   * Folders named after the {@link 
org.apache.hop.metadata.api.HopMetadata#legacyKeys()} of the
+   * managed class. Objects are still read from these folders, but they are 
always saved in the base
+   * folder.
+   */
+  @Setter @Getter protected List<String> legacyFolders;
+
   protected JsonMetadataParser<T> parser;
   protected IVariables variables;
 
@@ -67,7 +76,24 @@ public class JsonMetadataSerializer<T extends IHopMetadata> 
implements IHopMetad
       Class<T> managedClass,
       IVariables variables,
       String description) {
+    this(
+        metadataProvider,
+        baseFolder,
+        Collections.emptyList(),
+        managedClass,
+        variables,
+        description);
+  }
+
+  public JsonMetadataSerializer(
+      IHopMetadataProvider metadataProvider,
+      String baseFolder,
+      List<String> legacyFolders,
+      Class<T> managedClass,
+      IVariables variables,
+      String description) {
     this.metadataProvider = metadataProvider;
+    this.legacyFolders = legacyFolders == null ? Collections.emptyList() : 
legacyFolders;
     this.baseFolder = baseFolder;
     this.managedClass = managedClass;
     this.parser = new JsonMetadataParser<>(managedClass, metadataProvider);
@@ -78,10 +104,6 @@ public class JsonMetadataSerializer<T extends IHopMetadata> 
implements IHopMetad
   @Override
   public List<T> loadAll() throws HopException {
     List<T> list = new ArrayList<>();
-    validateBaseFolder(false);
-    if (!baseFolderExists) {
-      return list;
-    }
     List<String> names = listObjectNames();
     Collections.sort(names);
     for (String name : names) {
@@ -120,12 +142,11 @@ public class JsonMetadataSerializer<T extends 
IHopMetadata> implements IHopMetad
     if (name == null) {
       throw new HopException("Error: you need to specify the name of the 
metadata object to load");
     }
-    if (!exists(name)) {
+    String filename = findFilename(name);
+    if (filename == null) {
       return null;
     }
 
-    String filename = calculateFilename(name);
-
     try {
       // Load the JSON in a streaming fashion so we can parse the properties 
one by one...
       //
@@ -235,18 +256,86 @@ public class JsonMetadataSerializer<T extends 
IHopMetadata> implements IHopMetad
       throw new HopException(
           "Unable to save object '" + t.getName() + "' to JSON file '" + 
filename + "'", e);
     }
+
+    // The object now lives in the base folder: a copy in a legacy folder is 
outdated.
+    //
+    removeLegacyCopies(t.getName());
   }
 
+  /** Removes the files of an object from the legacy folders, moving it to the 
base folder. */
+  private void removeLegacyCopies(String name) throws HopException {
+    for (String legacyFolder : legacyFolders) {
+      // On a case-insensitive file system a key which only changed case is 
the same folder: we
+      // would delete the file we just saved.
+      if (legacyFolder.equalsIgnoreCase(baseFolder)) {
+        continue;
+      }
+      String legacyFilename = calculateFilename(legacyFolder, name);
+      try {
+        if (HopVfs.fileExists(legacyFilename)) {
+          HopVfs.getFileObject(legacyFilename).delete();
+          if (HopLogStore.isInitialized()) {
+            LogChannel.GENERAL.logBasic(
+                "Metadata object '"
+                    + name
+                    + "' was moved from legacy folder '"
+                    + legacyFolder
+                    + "' to folder '"
+                    + baseFolder
+                    + "'");
+          }
+        }
+      } catch (Exception e) {
+        throw new HopException(
+            "Error removing the legacy copy of metadata object '"
+                + name
+                + "' in file '"
+                + legacyFilename
+                + "'",
+            e);
+      }
+    }
+  }
+
+  /**
+   * @param name the name of the metadata object
+   * @return the file in the base folder where the object is saved
+   */
   public String calculateFilename(String name) {
-    return baseFolder + "/" + name + ".json";
+    return calculateFilename(baseFolder, name);
+  }
+
+  private static String calculateFilename(String folder, String name) {
+    return folder + "/" + name + ".json";
+  }
+
+  /**
+   * Finds the file which holds an existing object: in the base folder or, 
failing that, in one of
+   * the legacy folders.
+   *
+   * @param name the name of the metadata object
+   * @return the file of the object or null if it doesn't exist
+   * @throws HopException in case of a file system error
+   */
+  public String findFilename(String name) throws HopException {
+    validateBaseFolder(false);
+    if (baseFolderExists) {
+      String filename = calculateFilename(name);
+      if (HopVfs.fileExists(filename)) {
+        return filename;
+      }
+    }
+    for (String legacyFolder : legacyFolders) {
+      String filename = calculateFilename(legacyFolder, name);
+      if (HopVfs.fileExists(filename)) {
+        return filename;
+      }
+    }
+    return null;
   }
 
   @Override
   public T delete(String name) throws HopException {
-    // Make sure the base folder exists
-    //
-    validateBaseFolder(true);
-
     if (name == null) {
       throw new HopException(
           "Error: you need to specify the name of the metadata object to 
delete");
@@ -255,53 +344,69 @@ public class JsonMetadataSerializer<T extends 
IHopMetadata> implements IHopMetad
       throw new HopException("Error: Object '" + name + "' doesn't exist");
     }
     T t = load(name);
-    String filename = calculateFilename(name);
-    try {
-      boolean deleted = HopVfs.getFileObject(filename).delete();
-      if (!deleted) {
+
+    // The object can have been saved in the base folder while an older copy 
is still sitting in a
+    // legacy folder: remove them all or the old copy would come back. The 
legacy copies go first
+    // and the base folder file last: if a delete fails halfway, the file that 
is left is the one
+    // load() reads, never a stale legacy copy in its place.
+    //
+    List<String> folders = new ArrayList<>(legacyFolders);
+    folders.add(baseFolder);
+    for (String folder : folders) {
+      String filename = calculateFilename(folder, name);
+      if (!HopVfs.fileExists(filename)) {
+        continue;
+      }
+      try (FileObject file = HopVfs.getFileObject(filename)) {
+        if (!file.delete()) {
+          throw new HopException(
+              "Error: Object '" + name + "' could not be deleted, filename : " 
+ filename);
+        }
+      } catch (FileSystemException e) {
         throw new HopException(
-            "Error: Object '" + name + "' could not be deleted, filename : " + 
filename);
+            "Error deleting Object '" + name + "' with filename : " + 
filename, e);
       }
-    } catch (FileSystemException e) {
-      throw new HopException("Error deleting Object '" + name + "' with 
filename : " + filename);
     }
     return t;
   }
 
   @Override
   public List<String> listObjectNames() throws HopException {
-    List<String> names = new ArrayList<>();
+    // An object in the base folder and a legacy folder is listed once.
+    //
+    Set<String> names = new LinkedHashSet<>();
 
-    // Read-only access doesn't require a folder
+    // Read-only access doesn't require a folder.  If there is none, we simply 
don't have objects
+    // of the given type: this is not an error.
+    //
     validateBaseFolder(false);
-    if (!baseFolderExists) {
-      // This is not an error.  We simply don't have objects of the given type.
-      //
-      return names;
+    if (baseFolderExists) {
+      addObjectNames(baseFolder, names);
     }
+    for (String legacyFolder : legacyFolders) {
+      if (HopVfs.fileExists(legacyFolder)) {
+        addObjectNames(legacyFolder, names);
+      }
+    }
+    return new ArrayList<>(names);
+  }
 
-    FileObject folder = HopVfs.getFileObject(baseFolder);
-
+  private static void addObjectNames(String folderName, Set<String> names) 
throws HopException {
     try {
+      FileObject folder = HopVfs.getFileObject(folderName);
       List<FileObject> jsonFiles = HopVfs.findFiles(folder, "json", false);
       for (FileObject jsonFile : jsonFiles) {
         String baseName = jsonFile.getName().getBaseName();
         names.add(baseName.replaceAll("\\.json$", ""));
       }
-      return names;
     } catch (Exception e) {
-      throw new HopException("Error searching for JSON files", e);
+      throw new HopException("Error searching for JSON files in folder '" + 
folderName + "'", e);
     }
   }
 
   @Override
   public boolean exists(String name) throws HopException {
-    // Read-only access doesn't require a folder
-    validateBaseFolder(false);
-    if (!baseFolderExists) {
-      return false;
-    }
-    return HopVfs.fileExists(calculateFilename(name));
+    return findFilename(name) != null;
   }
 
   /**
@@ -314,12 +419,11 @@ public class JsonMetadataSerializer<T extends 
IHopMetadata> implements IHopMetad
     if (name == null) {
       throw new HopException("Error: you need to specify the name of the 
metadata object to load");
     }
-    validateBaseFolder(false);
-    if (!baseFolderExists || !exists(name)) {
+    String filename = findFilename(name);
+    if (filename == null) {
       throw new HopException("Object '" + name + "' does not exist");
     }
 
-    String filename = calculateFilename(name);
     try (InputStream fileInputStream = HopVfs.getInputStream(filename)) {
       JsonFactory jsonFactory = new JsonFactory();
       try (com.fasterxml.jackson.core.JsonParser jsonParser =
diff --git 
a/core/src/main/java/org/apache/hop/metadata/util/HopMetadataUtil.java 
b/core/src/main/java/org/apache/hop/metadata/util/HopMetadataUtil.java
index f8b2a4144d..92bfc3ad86 100644
--- a/core/src/main/java/org/apache/hop/metadata/util/HopMetadataUtil.java
+++ b/core/src/main/java/org/apache/hop/metadata/util/HopMetadataUtil.java
@@ -23,11 +23,14 @@ import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.IHopMetadata;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.api.IHopMetadataSerializer;
 import org.apache.hop.metadata.serializer.json.JsonMetadataProvider;
+import org.apache.hop.metadata.serializer.json.JsonMetadataSerializer;
 import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
 
 public class HopMetadataUtil {
@@ -62,11 +65,85 @@ public class HopMetadataUtil {
     return new MultiMetadataProvider(Encr.getEncoder(), providers, variables);
   }
 
+  /**
+   * The file holding a metadata object, found the way {@link
+   * 
org.apache.hop.metadata.serializer.multi.MultiMetadataSerializer#load(String)} 
finds the object
+   * itself: the last provider which has it wins, which is the child project 
when a project inherits
+   * from another. Within a file based provider the folder of the current key 
comes before the
+   * folders of the {@link HopMetadata#legacyKeys()}.
+   *
+   * @param provider the provider, can be a multi-provider
+   * @param managedClass the metadata type
+   * @param name the name of the metadata object
+   * @return the file, or null when no provider has the object or the one 
which has it is not file
+   *     based
+   * @throws HopException in case of a file system error
+   */
+  public static <T extends IHopMetadata> String findFilename(
+      IHopMetadataProvider provider, Class<T> managedClass, String name) 
throws HopException {
+    List<IHopMetadataProvider> providers = getProviders(provider);
+    for (int i = providers.size() - 1; i >= 0; i--) {
+      IHopMetadataSerializer<T> serializer = 
providers.get(i).getSerializer(managedClass);
+      if (serializer instanceof JsonMetadataSerializer jsonSerializer) {
+        String filename = jsonSerializer.findFilename(name);
+        if (filename != null) {
+          return filename;
+        }
+      } else if (serializer.exists(name)) {
+        return null;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * The providers behind a provider, a multi-provider unwrapped, in the order 
it lists them: the
+   * parent project first, the child project last.
+   */
+  public static List<IHopMetadataProvider> getProviders(IHopMetadataProvider 
provider) {
+    List<IHopMetadataProvider> providers = new ArrayList<>();
+    if (provider instanceof MultiMetadataProvider multiProvider) {
+      for (IHopMetadataProvider childProvider : multiProvider.getProviders()) {
+        providers.addAll(getProviders(childProvider));
+      }
+    } else if (provider != null) {
+      providers.add(provider);
+    }
+    return providers;
+  }
+
   public static <T extends IHopMetadata> HopMetadata getHopMetadataAnnotation(
       Class<T> managedClass) {
     return managedClass.getAnnotation(HopMetadata.class);
   }
 
+  /**
+   * The current key of a metadata type followed by its {@link 
HopMetadata#legacyKeys()}. This is
+   * the order in which to look for stored objects: the current key always 
wins.
+   *
+   * @param annotation the metadata type annotation
+   * @return the current key and the legacy keys, without empty or duplicate 
values
+   */
+  public static List<String> getAllKeys(HopMetadata annotation) {
+    List<String> keys = new ArrayList<>();
+    keys.add(Const.NVL(annotation.key(), annotation.name()));
+    for (String legacyKey : annotation.legacyKeys()) {
+      if (StringUtils.isNotEmpty(legacyKey) && !keys.contains(legacyKey)) {
+        keys.add(legacyKey);
+      }
+    }
+    return keys;
+  }
+
+  /**
+   * @param annotation the metadata type annotation
+   * @param key a metadata type key, for example from a filter or a serialized 
metadata export
+   * @return true if the key is the current key or one of the legacy keys of 
the metadata type
+   */
+  public static boolean matchesKey(HopMetadata annotation, String key) {
+    return annotation != null && key != null && 
getAllKeys(annotation).contains(key);
+  }
+
   public static String[] getHopMetadataKeys(IHopMetadataProvider provider) {
     List<String> keys = new ArrayList<>();
     for (Class<IHopMetadata> metadataClass : provider.getMetadataClasses()) {
diff --git 
a/core/src/test/java/org/apache/hop/metadata/serializer/MetadataGuiFlows.java 
b/core/src/test/java/org/apache/hop/metadata/serializer/MetadataGuiFlows.java
new file mode 100644
index 0000000000..ebe12ee59a
--- /dev/null
+++ 
b/core/src/test/java/org/apache/hop/metadata/serializer/MetadataGuiFlows.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.metadata.serializer;
+
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.metadata.api.IHopMetadataSerializer;
+
+/**
+ * The serializer calls the Hop GUI makes to rename and duplicate metadata 
objects, without the GUI
+ * around them. Keep these in sync with the classes they mirror so the tests 
using them exercise
+ * what users actually do.
+ */
+public final class MetadataGuiFlows {
+
+  private MetadataGuiFlows() {}
+
+  /**
+   * Renaming an element in the metadata perspective tree: {@code 
MetadataManager.rename()}.
+   *
+   * @return false if the new name is already taken, like the GUI which 
refuses the rename
+   */
+  public static <T extends IHopMetadata> boolean renameInTree(
+      IHopMetadataSerializer<T> serializer, String oldName, String newName) 
throws HopException {
+    if (serializer.exists(newName)) {
+      return false;
+    }
+    T metadata = serializer.load(oldName);
+    metadata.setName(newName);
+    serializer.save(metadata);
+    serializer.delete(oldName);
+    return true;
+  }
+
+  /**
+   * Changing the name of an open element in its editor and saving it: {@code
+   * MetadataEditor.save()}.
+   *
+   * @throws HopException if the new name is already taken, like the GUI
+   */
+  public static <T extends IHopMetadata> void renameInEditor(
+      IHopMetadataSerializer<T> serializer, T metadata, String originalName) 
throws HopException {
+    boolean isRename = false;
+    if (!originalName.equals(metadata.getName())) {
+      if (serializer.exists(metadata.getName())) {
+        throw new HopException("Name '" + metadata.getName() + "' already 
exists");
+      }
+      isRename = true;
+    }
+    serializer.save(metadata);
+    if (isRename && serializer.exists(originalName)) {
+      serializer.delete(originalName);
+    }
+  }
+
+  /**
+   * Duplicating an element in the metadata perspective: {@code
+   * MetadataPerspective.duplicateMetadata()}.
+   *
+   * @return the name of the copy
+   */
+  public static <T extends IHopMetadata> String duplicate(
+      IHopMetadataSerializer<T> serializer, String name) throws HopException {
+    T metadata = serializer.load(name);
+    int copyNr = 2;
+    while (true) {
+      String newName = name + " " + copyNr;
+      if (!serializer.exists(newName)) {
+        metadata.setName(newName);
+        serializer.save(metadata);
+        return newName;
+      }
+      copyNr++;
+    }
+  }
+}
diff --git 
a/core/src/test/java/org/apache/hop/metadata/serializer/json/JsonMetadataLegacyKeysTest.java
 
b/core/src/test/java/org/apache/hop/metadata/serializer/json/JsonMetadataLegacyKeysTest.java
new file mode 100644
index 0000000000..c92e5787f2
--- /dev/null
+++ 
b/core/src/test/java/org/apache/hop/metadata/serializer/json/JsonMetadataLegacyKeysTest.java
@@ -0,0 +1,631 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.metadata.serializer.json;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.encryption.HopTwoWayPasswordEncoder;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.HopMetadata;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.metadata.serializer.BaseMetadataProvider;
+import org.apache.hop.metadata.serializer.MetadataGuiFlows;
+import org.apache.hop.metadata.serializer.json.occupation.Occupation;
+import org.apache.hop.metadata.serializer.json.renamed.CaseRenamedType;
+import org.apache.hop.metadata.serializer.json.renamed.RenamedType;
+import org.apache.hop.metadata.serializer.json.renamed.TwiceRenamedType;
+import org.apache.hop.metadata.util.HopMetadataUtil;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * A metadata type which is renamed keeps its old key in {@link 
HopMetadata#legacyKeys()}: objects
+ * in the old folder are still loaded, and saving them moves them to the new 
folder (issue #5597).
+ *
+ * <p>Most tests use a "mixed" project, which is what users have while a 
project migrates: some
+ * objects only in the legacy folder, some only in the current folder and some 
in both.
+ */
+class JsonMetadataLegacyKeysTest {
+
+  @TempDir Path folder;
+
+  private JsonMetadataProvider provider;
+  private JsonMetadataSerializer<RenamedType> serializer;
+  private Path legacyFolder;
+  private Path currentFolder;
+
+  @BeforeEach
+  void setUp() throws Exception {
+    provider =
+        new JsonMetadataProvider(
+            new HopTwoWayPasswordEncoder(),
+            folder.toString(),
+            Variables.getADefaultVariableSpace());
+    serializer = (JsonMetadataSerializer<RenamedType>) 
provider.getSerializer(RenamedType.class);
+    legacyFolder = folder.resolve("RenamedType");
+    currentFolder = folder.resolve("renamed-type");
+  }
+
+  private static void writeObject(Path typeFolder, String name, String 
description)
+      throws Exception {
+    Files.createDirectories(typeFolder);
+    Files.write(
+        typeFolder.resolve(name + ".json"),
+        ("{\"name\":\"" + name + "\",\"description\":\"" + description + 
"\"}").getBytes(UTF_8));
+  }
+
+  /**
+   * Writes an object the way an older version of Hop did: in the folder named 
after the old key.
+   */
+  private void writeLegacyObject(String name, String description) throws 
Exception {
+    writeObject(legacyFolder, name, description);
+  }
+
+  private void writeCurrentObject(String name, String description) throws 
Exception {
+    writeObject(currentFolder, name, description);
+  }
+
+  /** legacy-only: only in the old folder, current-only: only in the new one, 
both: in both. */
+  private void writeMixedProject() throws Exception {
+    writeLegacyObject("legacy-only", "legacy");
+    writeCurrentObject("current-only", "current");
+    writeLegacyObject("both", "outdated legacy copy");
+    writeCurrentObject("both", "current copy");
+  }
+
+  private boolean inLegacy(String name) {
+    return Files.exists(legacyFolder.resolve(name + ".json"));
+  }
+
+  private boolean inCurrent(String name) {
+    return Files.exists(currentFolder.resolve(name + ".json"));
+  }
+
+  private List<String> sortedNames() throws HopException {
+    List<String> names = new ArrayList<>(serializer.listObjectNames());
+    names.sort(String::compareTo);
+    return names;
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // Reading
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  void testLoadFromLegacyFolder() throws Exception {
+    writeLegacyObject("old", "Saved by an older version");
+
+    assertEquals(List.of("old"), serializer.listObjectNames());
+    assertTrue(serializer.exists("old"));
+    RenamedType loaded = serializer.load("old");
+    assertNotNull(loaded);
+    assertEquals("Saved by an older version", loaded.getDescription());
+    assertEquals(1, serializer.loadAll().size());
+    assertEquals(
+        legacyFolder.resolve("old.json").toString(),
+        Path.of(serializer.findFilename("old")).toString());
+
+    // Only reading doesn't move anything.
+    assertFalse(Files.exists(currentFolder));
+  }
+
+  @Test
+  void testMixedProjectIsReadAsOne() throws Exception {
+    writeMixedProject();
+
+    assertEquals(List.of("both", "current-only", "legacy-only"), 
sortedNames());
+    assertTrue(serializer.exists("legacy-only"));
+    assertTrue(serializer.exists("current-only"));
+    assertTrue(serializer.exists("both"));
+    assertFalse(serializer.exists("nowhere"));
+    assertNull(serializer.load("nowhere"));
+    assertNull(serializer.findFilename("nowhere"));
+
+    assertEquals("legacy", serializer.load("legacy-only").getDescription());
+    assertEquals("current", serializer.load("current-only").getDescription());
+    // The current folder wins over the legacy folder.
+    assertEquals("current copy", serializer.load("both").getDescription());
+    assertEquals(
+        currentFolder.resolve("both.json").toString(),
+        Path.of(serializer.findFilename("both")).toString());
+
+    List<RenamedType> all = serializer.loadAll();
+    assertEquals(3, all.size());
+    assertTrue(all.stream().anyMatch(t -> "current 
copy".equals(t.getDescription())));
+    assertFalse(all.stream().anyMatch(t -> "outdated legacy 
copy".equals(t.getDescription())));
+  }
+
+  /** The provider name is how a multi-provider knows where to save an object 
back to. */
+  @Test
+  void testObjectsFromLegacyFolderKnowTheirProvider() throws Exception {
+    writeLegacyObject("old", "legacy");
+
+    assertEquals(provider.getDescription(), 
serializer.load("old").getMetadataProviderName());
+  }
+
+  @Test
+  void testReadVirtualPath() throws Exception {
+    Files.createDirectories(legacyFolder);
+    Files.write(
+        legacyFolder.resolve("filed.json"),
+        
"{\"name\":\"filed\",\"virtualPath\":\"/legacy/path\"}".getBytes(UTF_8));
+    Files.write(
+        legacyFolder.resolve("both.json"),
+        
"{\"name\":\"both\",\"virtualPath\":\"/legacy/path\"}".getBytes(UTF_8));
+    Files.createDirectories(currentFolder);
+    Files.write(
+        currentFolder.resolve("both.json"),
+        
"{\"name\":\"both\",\"virtualPath\":\"/current/path\"}".getBytes(UTF_8));
+
+    assertEquals("/legacy/path", serializer.readVirtualPath("filed"));
+    assertEquals("/current/path", serializer.readVirtualPath("both"));
+    assertThrows(HopException.class, () -> 
serializer.readVirtualPath("nowhere"));
+  }
+
+  @Test
+  void testNoFoldersAtAll() throws Exception {
+    assertTrue(serializer.listObjectNames().isEmpty());
+    assertTrue(serializer.loadAll().isEmpty());
+    assertFalse(serializer.exists("anything"));
+
+    // Reading never creates folders.
+    assertFalse(Files.exists(currentFolder));
+    assertFalse(Files.exists(legacyFolder));
+  }
+
+  @Test
+  void testOnlyJsonFilesDirectlyInTheLegacyFolderAreObjects() throws Exception 
{
+    writeLegacyObject("real", "legacy");
+    Files.write(legacyFolder.resolve("notes.txt"), "not 
metadata".getBytes(UTF_8));
+    Files.createDirectories(legacyFolder.resolve("sub"));
+    writeObject(legacyFolder.resolve("sub"), "nested", "not listed");
+
+    assertEquals(List.of("real"), serializer.listObjectNames());
+  }
+
+  @Test
+  void testNamesWithSpacesAndDotsInLegacyFolder() throws Exception {
+    writeLegacyObject("My connection v1.2", "legacy");
+
+    assertEquals(List.of("My connection v1.2"), serializer.listObjectNames());
+    assertEquals("legacy", serializer.load("My connection 
v1.2").getDescription());
+
+    RenamedType loaded = serializer.load("My connection v1.2");
+    serializer.save(loaded);
+    assertTrue(inCurrent("My connection v1.2"));
+    assertFalse(inLegacy("My connection v1.2"));
+  }
+
+  /** A broken legacy object shouldn't hide the others, while loading it by 
name still reports. */
+  @Test
+  void testCorruptLegacyObjectIsSkippedByLoadAll() throws Exception {
+    writeMixedProject();
+    Files.write(legacyFolder.resolve("corrupt.json"), "{ \"name\": 
".getBytes(UTF_8));
+
+    assertEquals(4, serializer.listObjectNames().size());
+    assertEquals(3, serializer.loadAll().size());
+    assertThrows(HopException.class, () -> serializer.load("corrupt"));
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // Saving
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  void testSaveMovesObjectToCurrentFolder() throws Exception {
+    writeLegacyObject("old", "Saved by an older version");
+
+    RenamedType loaded = serializer.load("old");
+    loaded.setDescription("Changed");
+    serializer.save(loaded);
+
+    assertTrue(inCurrent("old"));
+    assertFalse(inLegacy("old"));
+    assertEquals(List.of("old"), serializer.listObjectNames());
+    assertEquals("Changed", serializer.load("old").getDescription());
+  }
+
+  @Test
+  void testSavingOneObjectOnlyMovesThatObject() throws Exception {
+    writeMixedProject();
+    writeLegacyObject("other-legacy", "legacy");
+
+    serializer.save(serializer.load("legacy-only"));
+
+    assertTrue(inCurrent("legacy-only"));
+    assertFalse(inLegacy("legacy-only"));
+    // Untouched objects stay where they are.
+    assertTrue(inLegacy("other-legacy"));
+    assertFalse(inCurrent("other-legacy"));
+    assertTrue(inLegacy("both"));
+    assertEquals(List.of("both", "current-only", "legacy-only", 
"other-legacy"), sortedNames());
+  }
+
+  /** Saving an object in both folders removes the outdated legacy copy. */
+  @Test
+  void testSavingAnObjectInBothFoldersRemovesTheLegacyCopy() throws Exception {
+    writeMixedProject();
+
+    RenamedType both = serializer.load("both");
+    both.setDescription("saved again");
+    serializer.save(both);
+
+    assertTrue(inCurrent("both"));
+    assertFalse(inLegacy("both"));
+    assertEquals("saved again", serializer.load("both").getDescription());
+  }
+
+  @Test
+  void testNewObjectsGoToCurrentFolder() throws Exception {
+    serializer.save(new RenamedType("new", "Brand new"));
+
+    assertTrue(inCurrent("new"));
+    assertFalse(Files.exists(legacyFolder));
+  }
+
+  @Test
+  void testSavingACurrentObjectDoesNotTouchTheLegacyFolder() throws Exception {
+    writeMixedProject();
+
+    serializer.save(serializer.load("current-only"));
+
+    assertTrue(inCurrent("current-only"));
+    assertFalse(inLegacy("current-only"));
+    assertTrue(inLegacy("legacy-only"));
+  }
+
+  @Test
+  void testMigrateAWholeProjectBySavingEverything() throws Exception {
+    writeMixedProject();
+    writeLegacyObject("another", "legacy");
+
+    for (RenamedType t : serializer.loadAll()) {
+      serializer.save(t);
+    }
+
+    try (var files = Files.list(legacyFolder)) {
+      assertEquals(0, files.count(), "the legacy folder is empty after saving 
everything");
+    }
+    assertEquals(List.of("another", "both", "current-only", "legacy-only"), 
sortedNames());
+    assertEquals("current copy", serializer.load("both").getDescription());
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // Deleting
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  void testDeleteRemovesAllCopies() throws Exception {
+    writeMixedProject();
+
+    RenamedType deleted = serializer.delete("both");
+
+    // The object returned is the one the user saw: the current copy.
+    assertEquals("current copy", deleted.getDescription());
+    assertFalse(serializer.exists("both"));
+    assertFalse(inCurrent("both"));
+    assertFalse(inLegacy("both"));
+    assertEquals(List.of("current-only", "legacy-only"), sortedNames());
+  }
+
+  @Test
+  void testDeleteLegacyOnlyObject() throws Exception {
+    writeLegacyObject("old", "legacy");
+
+    serializer.delete("old");
+
+    assertFalse(serializer.exists("old"));
+    assertFalse(inLegacy("old"));
+    // Deleting doesn't create the current folder as a side effect.
+    assertFalse(Files.exists(currentFolder));
+  }
+
+  /**
+   * A delete that fails on a legacy copy must leave the current file in 
place: the legacy copies go
+   * first, so the stale one can never end up being the object.
+   */
+  @Test
+  void testFailedDeleteOfALegacyCopyKeepsTheCurrentFile() throws Exception {
+    writeCurrentObject("stuck", "current copy");
+    // A folder with something in it, where the legacy copy would be: it can't 
be deleted.
+    Path undeletable = legacyFolder.resolve("stuck.json");
+    Files.createDirectories(undeletable);
+    Files.writeString(undeletable.resolve("keep"), "x");
+
+    assertThrows(HopException.class, () -> serializer.delete("stuck"));
+
+    assertTrue(inCurrent("stuck"));
+    assertEquals("current copy", serializer.load("stuck").getDescription());
+  }
+
+  @Test
+  void testDeleteUnknownObjectFails() {
+    assertThrows(HopException.class, () -> serializer.delete("nowhere"));
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // Renaming, as the GUI does it
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  void testRenameLegacyObjectInTree() throws Exception {
+    writeMixedProject();
+
+    assertTrue(MetadataGuiFlows.renameInTree(serializer, "legacy-only", 
"renamed"));
+
+    assertTrue(inCurrent("renamed"));
+    assertFalse(inLegacy("legacy-only"));
+    assertFalse(inCurrent("legacy-only"));
+    assertEquals("legacy", serializer.load("renamed").getDescription());
+    assertEquals(List.of("both", "current-only", "renamed"), sortedNames());
+  }
+
+  @Test
+  void testRenameObjectInBothFoldersInTree() throws Exception {
+    writeMixedProject();
+
+    assertTrue(MetadataGuiFlows.renameInTree(serializer, "both", "renamed"));
+
+    // The renamed object is the current copy, and no stale copy of the old 
name is left behind to
+    // reappear under the old name.
+    assertEquals("current copy", serializer.load("renamed").getDescription());
+    assertFalse(serializer.exists("both"));
+    assertFalse(inLegacy("both"));
+    assertFalse(inCurrent("both"));
+  }
+
+  @Test
+  void testRenameLegacyObjectInEditor() throws Exception {
+    writeMixedProject();
+
+    RenamedType metadata = serializer.load("legacy-only");
+    metadata.setName("renamed");
+    metadata.setDescription("edited");
+    MetadataGuiFlows.renameInEditor(serializer, metadata, "legacy-only");
+
+    assertTrue(inCurrent("renamed"));
+    assertFalse(serializer.exists("legacy-only"));
+    assertEquals("edited", serializer.load("renamed").getDescription());
+  }
+
+  /** Saving an open editor without changing the name is a plain save: it 
migrates the object. */
+  @Test
+  void testSaveInEditorWithoutRename() throws Exception {
+    writeLegacyObject("old", "legacy");
+
+    RenamedType metadata = serializer.load("old");
+    metadata.setDescription("edited");
+    MetadataGuiFlows.renameInEditor(serializer, metadata, "old");
+
+    assertTrue(inCurrent("old"));
+    assertFalse(inLegacy("old"));
+    assertEquals("edited", serializer.load("old").getDescription());
+  }
+
+  /** A name only used in the legacy folder is still taken: renaming onto it 
would lose data. */
+  @Test
+  void testRenameOntoNameInLegacyFolderIsRefused() throws Exception {
+    writeMixedProject();
+
+    assertFalse(MetadataGuiFlows.renameInTree(serializer, "current-only", 
"legacy-only"));
+    RenamedType metadata = serializer.load("current-only");
+    metadata.setName("legacy-only");
+    assertThrows(
+        HopException.class,
+        () -> MetadataGuiFlows.renameInEditor(serializer, metadata, 
"current-only"));
+
+    // Nothing changed.
+    assertEquals("legacy", serializer.load("legacy-only").getDescription());
+    assertEquals("current", serializer.load("current-only").getDescription());
+    assertEquals(List.of("both", "current-only", "legacy-only"), 
sortedNames());
+  }
+
+  @Test
+  void testRenameOntoNameInCurrentFolderIsRefused() throws Exception {
+    writeMixedProject();
+
+    assertFalse(MetadataGuiFlows.renameInTree(serializer, "legacy-only", 
"current-only"));
+    assertTrue(inLegacy("legacy-only"));
+    assertEquals("current", serializer.load("current-only").getDescription());
+  }
+
+  @Test
+  void testRenameBackAndForth() throws Exception {
+    writeLegacyObject("a", "legacy");
+
+    assertTrue(MetadataGuiFlows.renameInTree(serializer, "a", "b"));
+    assertTrue(MetadataGuiFlows.renameInTree(serializer, "b", "a"));
+
+    assertEquals(List.of("a"), serializer.listObjectNames());
+    assertTrue(inCurrent("a"));
+    assertFalse(inLegacy("a"));
+    assertEquals("legacy", serializer.load("a").getDescription());
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // Duplicating, as the GUI does it
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  void testDuplicateLegacyObject() throws Exception {
+    writeLegacyObject("conn", "legacy");
+
+    String copy = MetadataGuiFlows.duplicate(serializer, "conn");
+
+    assertEquals("conn 2", copy);
+    assertTrue(inCurrent("conn 2"));
+    assertEquals("legacy", serializer.load("conn 2").getDescription());
+    // The original isn't touched: duplicating isn't editing it.
+    assertTrue(inLegacy("conn"));
+    assertFalse(inCurrent("conn"));
+    assertEquals(List.of("conn", "conn 2"), sortedNames());
+  }
+
+  /** A copy name used in the legacy folder is taken, so the next number is 
used. */
+  @Test
+  void testDuplicateSkipsNamesTakenInEitherFolder() throws Exception {
+    writeCurrentObject("conn", "current");
+    writeLegacyObject("conn 2", "legacy copy");
+    writeCurrentObject("conn 3", "current copy");
+
+    String copy = MetadataGuiFlows.duplicate(serializer, "conn");
+
+    assertEquals("conn 4", copy);
+    assertEquals("current", serializer.load("conn 4").getDescription());
+    assertEquals("legacy copy", serializer.load("conn 2").getDescription());
+    assertTrue(inLegacy("conn 2"));
+    assertEquals(List.of("conn", "conn 2", "conn 3", "conn 4"), sortedNames());
+  }
+
+  @Test
+  void testDuplicateObjectInBothFoldersCopiesTheCurrentCopy() throws Exception 
{
+    writeMixedProject();
+
+    String copy = MetadataGuiFlows.duplicate(serializer, "both");
+
+    assertEquals("current copy", serializer.load(copy).getDescription());
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // Types renamed more than once, or only in case
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  void testTypeRenamedTwice() throws Exception {
+    JsonMetadataSerializer<TwiceRenamedType> twice =
+        (JsonMetadataSerializer<TwiceRenamedType>) 
provider.getSerializer(TwiceRenamedType.class);
+    Path current = folder.resolve("twice-renamed");
+    Path previous = folder.resolve("TwiceRenamed");
+    Path original = folder.resolve("OriginalName");
+
+    writeObject(original, "only-original", "original");
+    writeObject(previous, "only-previous", "previous");
+    writeObject(original, "previous-and-original", "original");
+    writeObject(previous, "previous-and-original", "previous");
+    writeObject(original, "everywhere", "original");
+    writeObject(previous, "everywhere", "previous");
+    writeObject(current, "everywhere", "current");
+
+    List<String> names = new ArrayList<>(twice.listObjectNames());
+    names.sort(String::compareTo);
+    assertEquals(
+        List.of("everywhere", "only-original", "only-previous", 
"previous-and-original"), names);
+
+    // The current key wins, then the most recent legacy key.
+    assertEquals("current", twice.load("everywhere").getDescription());
+    assertEquals("previous", 
twice.load("previous-and-original").getDescription());
+    assertEquals("original", twice.load("only-original").getDescription());
+
+    // Saving removes the object from every legacy folder.
+    twice.save(twice.load("everywhere"));
+    twice.save(twice.load("only-original"));
+    assertFalse(Files.exists(original.resolve("everywhere.json")));
+    assertFalse(Files.exists(previous.resolve("everywhere.json")));
+    assertFalse(Files.exists(original.resolve("only-original.json")));
+    assertTrue(Files.exists(current.resolve("only-original.json")));
+
+    // Deleting removes every copy.
+    twice.delete("previous-and-original");
+    assertFalse(twice.exists("previous-and-original"));
+    assertFalse(Files.exists(original.resolve("previous-and-original.json")));
+    assertFalse(Files.exists(previous.resolve("previous-and-original.json")));
+  }
+
+  /**
+   * A key which only changed case is the same folder on a case-insensitive 
file system (macOS,
+   * Windows). Saving must then never delete the file it just wrote. On a 
case-sensitive file system
+   * the old copy stays behind but the current one wins, so either way the 
saved object is loaded.
+   */
+  @Test
+  void testCaseOnlyRenameNeverLosesTheSavedObject() throws Exception {
+    JsonMetadataSerializer<CaseRenamedType> caseSerializer =
+        (JsonMetadataSerializer<CaseRenamedType>) 
provider.getSerializer(CaseRenamedType.class);
+    writeObject(folder.resolve("Case-Renamed"), "obj", "old");
+
+    CaseRenamedType loaded = caseSerializer.load("obj");
+    assertEquals("old", loaded.getDescription());
+    loaded.setDescription("saved");
+    caseSerializer.save(loaded);
+
+    
assertTrue(Files.exists(folder.resolve("case-renamed").resolve("obj.json")));
+    assertEquals("saved", caseSerializer.load("obj").getDescription());
+    assertEquals(List.of("obj"), caseSerializer.listObjectNames());
+
+    caseSerializer.delete("obj");
+    assertFalse(caseSerializer.exists("obj"));
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // Keys
+  // 
---------------------------------------------------------------------------------------------
+
+  @Test
+  void testAllKeysAndMatching() {
+    HopMetadata annotation = 
RenamedType.class.getAnnotation(HopMetadata.class);
+
+    assertEquals(List.of("renamed-type", "RenamedType"), 
HopMetadataUtil.getAllKeys(annotation));
+    assertTrue(HopMetadataUtil.matchesKey(annotation, "renamed-type"));
+    assertTrue(HopMetadataUtil.matchesKey(annotation, "RenamedType"));
+    assertFalse(HopMetadataUtil.matchesKey(annotation, "renamedtype"));
+    assertFalse(HopMetadataUtil.matchesKey(annotation, null));
+    assertFalse(HopMetadataUtil.matchesKey(null, "renamed-type"));
+
+    assertEquals(
+        List.of("twice-renamed", "TwiceRenamed", "OriginalName"),
+        
HopMetadataUtil.getAllKeys(TwiceRenamedType.class.getAnnotation(HopMetadata.class)));
+  }
+
+  /** A type without legacy keys only has its own folder. */
+  @Test
+  void testTypeWithoutLegacyKeys() throws Exception {
+    JsonMetadataSerializer<?> plain =
+        (JsonMetadataSerializer<?>) provider.getSerializer(Occupation.class);
+    assertTrue(plain.getLegacyFolders().isEmpty());
+  }
+
+  /** A serialized metadata export from an older version of Hop uses the 
legacy key. */
+  @Test
+  void testMetadataClassForLegacyKey() throws Exception {
+    BaseMetadataProvider base =
+        new BaseMetadataProvider(Variables.getADefaultVariableSpace(), "test") 
{
+          @Override
+          public <T extends IHopMetadata> List<Class<T>> getMetadataClasses() {
+            return List.of(
+                (Class<T>) (Class<?>) RenamedType.class,
+                (Class<T>) (Class<?>) TwiceRenamedType.class);
+          }
+        };
+
+    assertEquals(RenamedType.class, 
base.getMetadataClassForKey("RenamedType"));
+    assertEquals(TwiceRenamedType.class, 
base.getMetadataClassForKey("TwiceRenamed"));
+    assertEquals(TwiceRenamedType.class, 
base.getMetadataClassForKey("OriginalName"));
+    assertThrows(HopException.class, () -> 
base.getMetadataClassForKey("NoSuchType"));
+  }
+}
diff --git 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
 
b/core/src/test/java/org/apache/hop/metadata/serializer/json/renamed/CaseRenamedType.java
similarity index 50%
copy from 
plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
copy to 
core/src/test/java/org/apache/hop/metadata/serializer/json/renamed/CaseRenamedType.java
index 71586f010b..88f03a2baf 100644
--- 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
+++ 
b/core/src/test/java/org/apache/hop/metadata/serializer/json/renamed/CaseRenamedType.java
@@ -14,43 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.hop.vfs.gs.metadatatype;
 
-import java.io.Serializable;
+package org.apache.hop.metadata.serializer.json.renamed;
+
 import lombok.Getter;
 import lombok.Setter;
-import org.apache.hop.core.vfs.IVfsBrowseLocation;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
-import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
-import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
 
-@HopMetadata(
-    key = "GoogleStorageConnectionDefinition",
-    name = "i18n::GoogleStorageMetadataType.Name",
-    description = "i18n::GoogleStorageMetadataType.Description",
-    image = "ui/images/authentication.svg",
-    category = HopMetadataCategory.FILE_STORAGE,
-    documentationUrl = "/metadata-types/google-storage-authentication.html",
-    hopMetadataPropertyType = HopMetadataPropertyType.VFS_GCP_CONNECTION,
-    classLoaderGroup = "vfs-google")
+/** A metadata type whose key only changed case: from "Case-Renamed" to 
"case-renamed". */
 @Getter
 @Setter
-public class GoogleStorageMetadataType extends HopMetadataBase
-    implements Serializable, IHopMetadata, IVfsBrowseLocation {
+@HopMetadata(
+    name = "CaseRenamedType",
+    key = "case-renamed",
+    legacyKeys = {"Case-Renamed"})
+public class CaseRenamedType extends HopMetadataBase implements IHopMetadata {
 
-  private static final Class<?> PKG = GoogleStorageMetadataType.class;
   @HopMetadataProperty private String description;
 
-  @HopMetadataProperty(password = true)
-  private GoogleStorageCredentialsType storageCredentialsType;
-
-  @HopMetadataProperty(password = true)
-  private String storageAccountKey;
+  public CaseRenamedType() {}
 
-  public GoogleStorageMetadataType() {
-    // Do nothing
+  public CaseRenamedType(String name, String description) {
+    this.name = name;
+    this.description = description;
   }
 }
diff --git 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
 
b/core/src/test/java/org/apache/hop/metadata/serializer/json/renamed/RenamedType.java
similarity index 50%
copy from 
plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
copy to 
core/src/test/java/org/apache/hop/metadata/serializer/json/renamed/RenamedType.java
index 71586f010b..b12d6f9098 100644
--- 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
+++ 
b/core/src/test/java/org/apache/hop/metadata/serializer/json/renamed/RenamedType.java
@@ -14,43 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.hop.vfs.gs.metadatatype;
 
-import java.io.Serializable;
+package org.apache.hop.metadata.serializer.json.renamed;
+
 import lombok.Getter;
 import lombok.Setter;
-import org.apache.hop.core.vfs.IVfsBrowseLocation;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
-import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
-import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
 
-@HopMetadata(
-    key = "GoogleStorageConnectionDefinition",
-    name = "i18n::GoogleStorageMetadataType.Name",
-    description = "i18n::GoogleStorageMetadataType.Description",
-    image = "ui/images/authentication.svg",
-    category = HopMetadataCategory.FILE_STORAGE,
-    documentationUrl = "/metadata-types/google-storage-authentication.html",
-    hopMetadataPropertyType = HopMetadataPropertyType.VFS_GCP_CONNECTION,
-    classLoaderGroup = "vfs-google")
+/** A metadata type which was renamed from key "RenamedType" to 
"renamed-type". */
 @Getter
 @Setter
-public class GoogleStorageMetadataType extends HopMetadataBase
-    implements Serializable, IHopMetadata, IVfsBrowseLocation {
+@HopMetadata(
+    name = "Renamed type",
+    key = "renamed-type",
+    legacyKeys = {"RenamedType"})
+public class RenamedType extends HopMetadataBase implements IHopMetadata {
 
-  private static final Class<?> PKG = GoogleStorageMetadataType.class;
   @HopMetadataProperty private String description;
 
-  @HopMetadataProperty(password = true)
-  private GoogleStorageCredentialsType storageCredentialsType;
-
-  @HopMetadataProperty(password = true)
-  private String storageAccountKey;
+  public RenamedType() {}
 
-  public GoogleStorageMetadataType() {
-    // Do nothing
+  public RenamedType(String name, String description) {
+    this.name = name;
+    this.description = description;
   }
 }
diff --git 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
 
b/core/src/test/java/org/apache/hop/metadata/serializer/json/renamed/TwiceRenamedType.java
similarity index 50%
copy from 
plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
copy to 
core/src/test/java/org/apache/hop/metadata/serializer/json/renamed/TwiceRenamedType.java
index 71586f010b..02b0ec11f6 100644
--- 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
+++ 
b/core/src/test/java/org/apache/hop/metadata/serializer/json/renamed/TwiceRenamedType.java
@@ -14,43 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.hop.vfs.gs.metadatatype;
 
-import java.io.Serializable;
+package org.apache.hop.metadata.serializer.json.renamed;
+
 import lombok.Getter;
 import lombok.Setter;
-import org.apache.hop.core.vfs.IVfsBrowseLocation;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
-import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
-import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
 
-@HopMetadata(
-    key = "GoogleStorageConnectionDefinition",
-    name = "i18n::GoogleStorageMetadataType.Name",
-    description = "i18n::GoogleStorageMetadataType.Description",
-    image = "ui/images/authentication.svg",
-    category = HopMetadataCategory.FILE_STORAGE,
-    documentationUrl = "/metadata-types/google-storage-authentication.html",
-    hopMetadataPropertyType = HopMetadataPropertyType.VFS_GCP_CONNECTION,
-    classLoaderGroup = "vfs-google")
+/** A metadata type renamed twice: from "OriginalName" to "TwiceRenamed" to 
"twice-renamed". */
 @Getter
 @Setter
-public class GoogleStorageMetadataType extends HopMetadataBase
-    implements Serializable, IHopMetadata, IVfsBrowseLocation {
+@HopMetadata(
+    name = "TwiceRenamedType",
+    key = "twice-renamed",
+    legacyKeys = {"TwiceRenamed", "OriginalName"})
+public class TwiceRenamedType extends HopMetadataBase implements IHopMetadata {
 
-  private static final Class<?> PKG = GoogleStorageMetadataType.class;
   @HopMetadataProperty private String description;
 
-  @HopMetadataProperty(password = true)
-  private GoogleStorageCredentialsType storageCredentialsType;
-
-  @HopMetadataProperty(password = true)
-  private String storageAccountKey;
+  public TwiceRenamedType() {}
 
-  public GoogleStorageMetadataType() {
-    // Do nothing
+  public TwiceRenamedType(String name, String description) {
+    this.name = name;
+    this.description = description;
   }
 }
diff --git 
a/core/src/test/java/org/apache/hop/metadata/serializer/multi/MultiMetadataLegacyKeysTest.java
 
b/core/src/test/java/org/apache/hop/metadata/serializer/multi/MultiMetadataLegacyKeysTest.java
new file mode 100644
index 0000000000..2007b0f8d5
--- /dev/null
+++ 
b/core/src/test/java/org/apache/hop/metadata/serializer/multi/MultiMetadataLegacyKeysTest.java
@@ -0,0 +1,283 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.metadata.serializer.multi;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.encryption.HopTwoWayPasswordEncoder;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.api.IHopMetadataSerializer;
+import org.apache.hop.metadata.serializer.MetadataGuiFlows;
+import org.apache.hop.metadata.serializer.json.JsonMetadataProvider;
+import org.apache.hop.metadata.serializer.json.renamed.RenamedType;
+import org.apache.hop.metadata.util.HopMetadataUtil;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Renamed metadata types with several metadata folders: a parent project and 
a child project, or
+ * the folders listed in {@code HOP_METADATA_FOLDER}. Each folder can be 
anywhere in its migration,
+ * independently of the others (issue #5597).
+ */
+class MultiMetadataLegacyKeysTest {
+
+  @TempDir Path parentFolder;
+  @TempDir Path childFolder;
+
+  private MultiMetadataProvider multi;
+  private IHopMetadataSerializer<RenamedType> serializer;
+
+  @BeforeEach
+  void setUp() throws Exception {
+    HopTwoWayPasswordEncoder encoder = new HopTwoWayPasswordEncoder();
+    JsonMetadataProvider parent =
+        new JsonMetadataProvider(
+            encoder, parentFolder.toString(), 
Variables.getADefaultVariableSpace());
+    JsonMetadataProvider child =
+        new JsonMetadataProvider(
+            encoder, childFolder.toString(), 
Variables.getADefaultVariableSpace());
+    // Like a project with a parent project: the last provider is the child, 
which wins.
+    multi =
+        new MultiMetadataProvider(
+            encoder,
+            new ArrayList<IHopMetadataProvider>(List.of(parent, child)),
+            Variables.getADefaultVariableSpace());
+    serializer = multi.getSerializer(RenamedType.class);
+  }
+
+  private static void write(Path metadataFolder, String key, String name, 
String description)
+      throws Exception {
+    Path typeFolder = metadataFolder.resolve(key);
+    Files.createDirectories(typeFolder);
+    Files.write(
+        typeFolder.resolve(name + ".json"),
+        ("{\"name\":\"" + name + "\",\"description\":\"" + description + 
"\"}").getBytes(UTF_8));
+  }
+
+  private static boolean has(Path metadataFolder, String key, String name) {
+    return Files.exists(metadataFolder.resolve(key).resolve(name + ".json"));
+  }
+
+  private static final String LEGACY = "RenamedType";
+  private static final String CURRENT = "renamed-type";
+
+  private List<String> sortedNames() throws HopException {
+    List<String> names = new ArrayList<>(serializer.listObjectNames());
+    names.sort(String::compareTo);
+    return names;
+  }
+
+  @Test
+  void testChildOverridesParentWhateverTheFolders() throws Exception {
+    // Parent migrated, child didn't.
+    write(parentFolder, CURRENT, "a", "parent");
+    write(childFolder, LEGACY, "a", "child");
+    // Parent didn't migrate, child did.
+    write(parentFolder, LEGACY, "b", "parent");
+    write(childFolder, CURRENT, "b", "child");
+
+    assertEquals("child", serializer.load("a").getDescription());
+    assertEquals("child", serializer.load("b").getDescription());
+    assertEquals(List.of("a", "b"), sortedNames());
+    assertEquals(2, serializer.loadAll().size());
+    assertTrue(
+        serializer.loadAll().stream().allMatch(t -> 
"child".equals(t.getDescription())),
+        "loadAll gives the child's objects, like load");
+  }
+
+  /**
+   * The file of an object is the one load() reads: the child project before 
the parent, the current
+   * folder before the legacy one. That is what the metadata perspective opens 
and what a search
+   * result points at.
+   */
+  @Test
+  void testFilenameIsTheFileLoadReads() throws Exception {
+    // Parent migrated, child didn't.
+    write(parentFolder, CURRENT, "a", "parent");
+    write(childFolder, LEGACY, "a", "child");
+    // Parent didn't migrate, child did.
+    write(parentFolder, LEGACY, "b", "parent");
+    write(childFolder, CURRENT, "b", "child");
+    // Only the parent has it, not migrated.
+    write(parentFolder, LEGACY, "c", "parent");
+    // The child has an outdated legacy copy next to the current one.
+    write(childFolder, LEGACY, "d", "outdated");
+    write(childFolder, CURRENT, "d", "child");
+
+    assertFilename(childFolder, LEGACY, "a");
+    assertFilename(childFolder, CURRENT, "b");
+    assertFilename(parentFolder, LEGACY, "c");
+    assertFilename(childFolder, CURRENT, "d");
+    assertEquals("child", serializer.load("d").getDescription());
+    assertEquals(null, HopMetadataUtil.findFilename(multi, RenamedType.class, 
"nowhere"));
+  }
+
+  private void assertFilename(Path metadataFolder, String key, String name) 
throws Exception {
+    String filename = HopMetadataUtil.findFilename(multi, RenamedType.class, 
name);
+    assertEquals(
+        metadataFolder.resolve(key).resolve(name + ".json").toRealPath(),
+        Path.of(HopVfs.getFileObject(filename).getURL().toURI()).toRealPath(),
+        name);
+  }
+
+  @Test
+  void testMixedFoldersInEveryProvider() throws Exception {
+    write(parentFolder, LEGACY, "parent-legacy", "p");
+    write(parentFolder, CURRENT, "parent-current", "p");
+    write(childFolder, LEGACY, "child-legacy", "c");
+    write(childFolder, CURRENT, "child-current", "c");
+
+    assertEquals(
+        List.of("child-current", "child-legacy", "parent-current", 
"parent-legacy"), sortedNames());
+    for (String name : sortedNames()) {
+      assertTrue(serializer.exists(name), name);
+    }
+    assertEquals(4, serializer.loadAll().size());
+  }
+
+  /** An object is saved back to the project it came from, and moves there. */
+  @Test
+  void testSaveStaysInTheOwningProvider() throws Exception {
+    write(parentFolder, LEGACY, "shared", "parent");
+
+    RenamedType shared = serializer.load("shared");
+    shared.setDescription("edited");
+    serializer.save(shared);
+
+    assertTrue(has(parentFolder, CURRENT, "shared"));
+    assertFalse(has(parentFolder, LEGACY, "shared"));
+    assertFalse(has(childFolder, CURRENT, "shared"));
+    assertFalse(has(childFolder, LEGACY, "shared"));
+    assertEquals("edited", serializer.load("shared").getDescription());
+  }
+
+  @Test
+  void testNewObjectGoesToTheChildsCurrentFolder() throws Exception {
+    write(parentFolder, LEGACY, "existing", "parent");
+
+    serializer.save(new RenamedType("new", "new"));
+
+    assertTrue(has(childFolder, CURRENT, "new"));
+    assertFalse(Files.exists(childFolder.resolve(LEGACY)));
+    assertFalse(has(parentFolder, CURRENT, "new"));
+  }
+
+  @Test
+  void testRenameInTreeInParentProject() throws Exception {
+    write(parentFolder, LEGACY, "old", "parent");
+    write(childFolder, CURRENT, "unrelated", "child");
+
+    assertTrue(MetadataGuiFlows.renameInTree(serializer, "old", "new"));
+
+    assertTrue(has(parentFolder, CURRENT, "new"));
+    assertFalse(has(parentFolder, LEGACY, "old"));
+    assertFalse(serializer.exists("old"));
+    assertEquals("parent", serializer.load("new").getDescription());
+    assertEquals(List.of("new", "unrelated"), sortedNames());
+  }
+
+  @Test
+  void testRenameInEditorInChildProject() throws Exception {
+    write(childFolder, LEGACY, "old", "child");
+
+    RenamedType metadata = serializer.load("old");
+    metadata.setName("new");
+    MetadataGuiFlows.renameInEditor(serializer, metadata, "old");
+
+    assertTrue(has(childFolder, CURRENT, "new"));
+    assertFalse(has(childFolder, LEGACY, "old"));
+    assertEquals(List.of("new"), sortedNames());
+  }
+
+  /** Names are unique over all the projects and all the folders. */
+  @Test
+  void testRenameOntoNameInOtherProjectsLegacyFolderIsRefused() throws 
Exception {
+    write(parentFolder, LEGACY, "taken", "parent");
+    write(childFolder, CURRENT, "mine", "child");
+
+    assertFalse(MetadataGuiFlows.renameInTree(serializer, "mine", "taken"));
+    assertEquals("parent", serializer.load("taken").getDescription());
+    assertEquals("child", serializer.load("mine").getDescription());
+  }
+
+  /** The copy goes where the original lives, in the current folder. */
+  @Test
+  void testDuplicateInParentProject() throws Exception {
+    write(parentFolder, LEGACY, "conn", "parent");
+    write(childFolder, LEGACY, "conn 2", "child");
+
+    String copy = MetadataGuiFlows.duplicate(serializer, "conn");
+
+    assertEquals("conn 3", copy);
+    assertTrue(has(parentFolder, CURRENT, "conn 3"));
+    assertTrue(has(parentFolder, LEGACY, "conn"), "the original isn't moved");
+    assertTrue(has(childFolder, LEGACY, "conn 2"));
+    assertEquals(List.of("conn", "conn 2", "conn 3"), sortedNames());
+  }
+
+  /**
+   * Deleting an object the child overrides removes the child's copies only, 
in all its folders. The
+   * parent's object then shows through again, as it did before legacy keys 
existed.
+   */
+  @Test
+  void testDeleteOverriddenObject() throws Exception {
+    write(parentFolder, LEGACY, "x", "parent");
+    write(childFolder, LEGACY, "x", "child outdated");
+    write(childFolder, CURRENT, "x", "child");
+
+    RenamedType deleted = serializer.delete("x");
+
+    assertEquals("child", deleted.getDescription());
+    assertFalse(has(childFolder, CURRENT, "x"));
+    assertFalse(has(childFolder, LEGACY, "x"));
+    assertTrue(serializer.exists("x"));
+    assertEquals("parent", serializer.load("x").getDescription());
+
+    serializer.delete("x");
+    assertFalse(serializer.exists("x"));
+    assertThrows(HopException.class, () -> serializer.delete("x"));
+  }
+
+  @Test
+  void testReadVirtualPathFollowsTheSameOrder() throws Exception {
+    Path parentCurrent = parentFolder.resolve(CURRENT);
+    Files.createDirectories(parentCurrent);
+    Files.write(
+        parentCurrent.resolve("v.json"),
+        "{\"name\":\"v\",\"virtualPath\":\"/parent\"}".getBytes(UTF_8));
+    Path childLegacy = childFolder.resolve(LEGACY);
+    Files.createDirectories(childLegacy);
+    Files.write(
+        childLegacy.resolve("v.json"),
+        "{\"name\":\"v\",\"virtualPath\":\"/child\"}".getBytes(UTF_8));
+
+    assertEquals("/child", serializer.readVirtualPath("v"));
+  }
+}
diff --git a/docs/hop-dev-manual/modules/ROOT/pages/metadata-plugins.adoc 
b/docs/hop-dev-manual/modules/ROOT/pages/metadata-plugins.adoc
index e8b14f8bac..18fd64bfdb 100644
--- a/docs/hop-dev-manual/modules/ROOT/pages/metadata-plugins.adoc
+++ b/docs/hop-dev-manual/modules/ROOT/pages/metadata-plugins.adoc
@@ -28,7 +28,9 @@ The class which carries this annotation will contain the 
metadata.
 
 Here are the attributes of the @HopMetadata annotation:
 
-* key : this uniquely identifies the plugin and will be the name of the folder 
in which the metadata resides when serialising to JSON (see below)
+* key : this uniquely identifies the plugin and will be the name of the folder 
in which the metadata resides when serialising to JSON (see below).
+Use lower-case words separated by dashes, see <<metadata-key-naming>>.
+* legacyKeys : the keys this metadata type was known under before its key was 
renamed (optional), see <<metadata-key-renaming>>
 * name : a human-readable name
 * description : an extended description
 * image : the path to an image which helps identify the metadata in the Hop GUI
@@ -37,6 +39,46 @@ The class with this annotation will be found either because 
it lives in the ```p
 
 Example: 
https://github.com/apache/hop/blob/f8f2ad2d0bc0cfd143ae90cc32e73b6c9e1cda78/engine/src/main/java/org/apache/hop/partition/PartitionSchema.java#L47[PartitionSchema.java]
 
+[[metadata-key-naming]]
+=== Choosing a key
+
+The key is more than an internal ID.
+It is the name of the folder in a project which holds the metadata objects 
(`metadata/<key>/<name>.json`), it is written into the metadata exported for 
remote execution, and it is the ID used to disable the type in 
`disabledGuiElements`.
+Users see it and check it into version control, so it is effectively public.
+
+Use lower-case words separated by dashes (kebab-case) which describe the type, 
like the existing `pipeline-run-configuration`, `sftp-connection` or 
`mail-server-connection`.
+Don't use the Java class name or PascalCase (`MailServerConnection`): the 
folders in a project then no longer follow one convention.
+
+[[metadata-key-renaming]]
+=== Renaming a key
+
+Changing the key of an existing metadata type would make all the objects users 
already have invisible, because they live in a folder named after the old key.
+If you have to rename a key, move the old one to `legacyKeys`:
+
+[source,java]
+----
+@HopMetadata(
+    key = "mail-server-connection",
+    legacyKeys = {"MailServerConnection"},
+    name = "i18n::MailServerConnection.name",
+    ...)
+public class MailServerConnection extends HopMetadataBase implements 
IHopMetadata {
+----
+
+With a legacy key in place:
+
+* objects in the folder named after the legacy key are still listed and loaded.
+If an object exists in both folders, the one in the folder named after the 
current key wins.
+* saving an object always writes it to the folder named after the current key 
and removes the copy in the legacy folder.
+Projects move to the new folder one object at a time, as users edit them.
+* deleting an object removes it from all the folders.
+* a metadata export which uses the legacy key, for example one sent by an 
older Hop client to a Hop server, is still understood.
+
+Code which looks for metadata files or compares type keys should use 
`HopMetadataUtil.getAllKeys()`, `HopMetadataUtil.matchesKey()` or 
`JsonMetadataSerializer.findFilename()` rather than the `key` alone, so it also 
finds objects which haven't moved yet.
+
+Note that older Hop versions only know the old key: once an object has been 
saved by a newer version, older versions no longer see it.
+Mention the rename in the release notes.
+
 == Metadata Properties
 
 All properties you want to have as part of the shared Hop Metadata should get 
the ```*@HopMetadataProperty*``` annotation.
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/rest-connection.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/rest-connection.adoc
index 69c9caf47c..f5a6570cb0 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/rest-connection.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/rest-connection.adoc
@@ -189,4 +189,4 @@ TIP: For `LINK_HEADER` and `BODY_NEXT_URL`, the path on the 
REST client transfor
 
 == Samples
 
-link:https://github.com/apache/hop/blob/main/plugins/transforms/rest/src/main/samples/metadata/restconnection/github-releases.json[github-releases.json]
 — public GitHub API, `LINK_HEADER` pagination (used by 
link:https://github.com/apache/hop/blob/main/plugins/transforms/rest/src/main/samples/transforms/rest-client-github-releases-loop.hpl[rest-client-github-releases-loop.hpl]
 in the REST transform samples project).
\ No newline at end of file
+link:https://github.com/apache/hop/blob/main/plugins/transforms/rest/src/main/samples/metadata/rest-connection/github-releases.json[github-releases.json]
 — public GitHub API, `LINK_HEADER` pagination (used by 
link:https://github.com/apache/hop/blob/main/plugins/transforms/rest/src/main/samples/transforms/rest-client-github-releases-loop.hpl[rest-client-github-releases-loop.hpl]
 in the REST transform samples project).
\ No newline at end of file
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/rest.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/rest.adoc
index 36429c53b0..a65f09e14c 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/rest.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/rest.adoc
@@ -237,4 +237,4 @@ The block is built from the request that is actually sent, 
so it also shows what
 
 == Samples
 
-link:https://github.com/apache/hop/blob/main/plugins/transforms/rest/src/main/samples/transforms/rest-client-github-releases-loop.hpl[rest-client-github-releases-loop.hpl]
 — lists `apache/hop` GitHub releases via `LINK_HEADER` pagination 
(`per_page=5`), splits `+$[*]+`, and parses fields with a JSON Input transform. 
Connection metadata: 
link:https://github.com/apache/hop/blob/main/plugins/transforms/rest/src/main/samples/metadata/restconnection/github-releases.json[github-releases.json]
  [...]
+link:https://github.com/apache/hop/blob/main/plugins/transforms/rest/src/main/samples/transforms/rest-client-github-releases-loop.hpl[rest-client-github-releases-loop.hpl]
 — lists `apache/hop` GitHub releases via `LINK_HEADER` pagination 
(`per_page=5`), splits `+$[*]+`, and parses fields with a JSON Input transform. 
Connection metadata: 
link:https://github.com/apache/hop/blob/main/plugins/transforms/rest/src/main/samples/metadata/rest-connection/github-releases.json[github-releases.json]
 [...]
diff --git 
a/engine/src/main/java/org/apache/hop/metadata/refactor/MetadataReferenceFinder.java
 
b/engine/src/main/java/org/apache/hop/metadata/refactor/MetadataReferenceFinder.java
index 4c86b3629b..d7ac20d8d3 100644
--- 
a/engine/src/main/java/org/apache/hop/metadata/refactor/MetadataReferenceFinder.java
+++ 
b/engine/src/main/java/org/apache/hop/metadata/refactor/MetadataReferenceFinder.java
@@ -586,7 +586,7 @@ public class MetadataReferenceFinder {
    * Finds all pipeline and workflow files under the given root paths that 
contain a reference to
    * the given metadata element name (for the given metadata type key).
    *
-   * @param metadataKey metadata type key (e.g. "rdbms", "restconnection")
+   * @param metadataKey metadata type key (e.g. "rdbms", "rest-connection")
    * @param elementName the metadata element name to search for (e.g. 
connection name)
    * @param searchRootPaths root paths to search recursively for .hpl and .hwf 
files
    * @return list of files with at least one reference, and the count per file
diff --git 
a/engine/src/test/java/org/apache/hop/core/metadata/SerializableMetadataLegacyKeysTest.java
 
b/engine/src/test/java/org/apache/hop/core/metadata/SerializableMetadataLegacyKeysTest.java
new file mode 100644
index 0000000000..e45f80dd73
--- /dev/null
+++ 
b/engine/src/test/java/org/apache/hop/core/metadata/SerializableMetadataLegacyKeysTest.java
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.metadata;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.encryption.HopTwoWayPasswordEncoder;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.HopMetadata;
+import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.metadata.api.IHopMetadataSerializer;
+import org.apache.hop.metadata.serializer.json.JsonMetadataProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Metadata is serialized to JSON, keyed by metadata type, to ship it to a Hop 
server or another
+ * engine. Client and server can be different versions of Hop, so both keys of 
a renamed type have
+ * to be understood (issue #5597).
+ */
+class SerializableMetadataLegacyKeysTest {
+
+  /** A metadata type which was renamed from key "RenamedType" to 
"renamed-type". */
+  @Getter
+  @Setter
+  @HopMetadata(
+      name = "Renamed type",
+      key = "renamed-type",
+      legacyKeys = {"RenamedType"})
+  public static class RenamedType extends HopMetadataBase implements 
IHopMetadata {
+    @HopMetadataProperty private String description;
+
+    public RenamedType() {}
+  }
+
+  /** An unknown type in an export is logged and skipped. */
+  @BeforeAll
+  static void initLogging() {
+    HopLogStore.init();
+  }
+
+  /** The metadata types known in these tests, instead of the plugin registry. 
*/
+  private static <T extends IHopMetadata> List<Class<T>> testTypes() {
+    return List.of((Class<T>) (Class<?>) RenamedType.class);
+  }
+
+  private static SerializableMetadataProvider fromJson(String json) throws 
HopException {
+    return new SerializableMetadataProvider(json) {
+      @Override
+      public <T extends IHopMetadata> List<Class<T>> getMetadataClasses() {
+        return testTypes();
+      }
+    };
+  }
+
+  private static List<String> sortedNames(IHopMetadataSerializer<?> serializer)
+      throws HopException {
+    List<String> names = new ArrayList<>(serializer.listObjectNames());
+    names.sort(String::compareTo);
+    return names;
+  }
+
+  /** What an older Hop client sends: the objects under the legacy key. */
+  @Test
+  void testExportWithLegacyKeyIsLoaded() throws Exception {
+    String json = "{\"RenamedType\":[{\"name\":\"old\",\"description\":\"from 
an older client\"}]}";
+
+    SerializableMetadataProvider provider = fromJson(json);
+
+    IHopMetadataSerializer<RenamedType> serializer = 
provider.getSerializer(RenamedType.class);
+    assertEquals(List.of("old"), serializer.listObjectNames());
+    assertEquals("from an older client", 
serializer.load("old").getDescription());
+  }
+
+  @Test
+  void testExportWithCurrentKeyIsLoaded() throws Exception {
+    String json = 
"{\"renamed-type\":[{\"name\":\"new\",\"description\":\"current\"}]}";
+
+    SerializableMetadataProvider provider = fromJson(json);
+
+    assertEquals("current", 
provider.getSerializer(RenamedType.class).load("new").getDescription());
+  }
+
+  /** Should both keys ever end up in one export, the objects of both are 
loaded. */
+  @Test
+  void testExportWithBothKeysIsMerged() throws Exception {
+    String json =
+        "{\"RenamedType\":[{\"name\":\"a\",\"description\":\"legacy\"}],"
+            + 
"\"renamed-type\":[{\"name\":\"b\",\"description\":\"current\"}]}";
+
+    SerializableMetadataProvider provider = fromJson(json);
+
+    assertEquals(List.of("a", "b"), 
sortedNames(provider.getSerializer(RenamedType.class)));
+  }
+
+  @Test
+  void testExportWithUnknownKeyIsStillSkipped() throws Exception {
+    String json =
+        "{\"NoSuchType\":[{\"name\":\"x\"}],"
+            + "\"RenamedType\":[{\"name\":\"a\",\"description\":\"legacy\"}]}";
+
+    SerializableMetadataProvider provider = fromJson(json);
+
+    assertEquals(List.of("a"), 
provider.getSerializer(RenamedType.class).listObjectNames());
+  }
+
+  /**
+   * Exporting a project which is halfway its migration ships every object 
once, the current copy of
+   * an object in both folders, under the current key.
+   */
+  @Test
+  void testExportOfMixedProject(@TempDir Path folder) throws Exception {
+    write(folder.resolve("RenamedType"), "legacy-only", "legacy");
+    write(folder.resolve("RenamedType"), "both", "outdated legacy copy");
+    write(folder.resolve("renamed-type"), "both", "current copy");
+    write(folder.resolve("renamed-type"), "current-only", "current");
+
+    JsonMetadataProvider project =
+        new JsonMetadataProvider(
+            new HopTwoWayPasswordEncoder(),
+            folder.toString(),
+            Variables.getADefaultVariableSpace()) {
+          @Override
+          public <T extends IHopMetadata> List<Class<T>> getMetadataClasses() {
+            return testTypes();
+          }
+        };
+
+    SerializableMetadataProvider exported =
+        new SerializableMetadataProvider(project) {
+          @Override
+          public <T extends IHopMetadata> List<Class<T>> getMetadataClasses() {
+            return testTypes();
+          }
+        };
+    IHopMetadataSerializer<RenamedType> exportedSerializer =
+        exported.getSerializer(RenamedType.class);
+    assertEquals(List.of("both", "current-only", "legacy-only"), 
sortedNames(exportedSerializer));
+    assertEquals("current copy", 
exportedSerializer.load("both").getDescription());
+
+    String json = exported.toJson();
+    assertTrue(json.contains("\"renamed-type\""), json);
+    assertFalse(json.contains("\"RenamedType\""), json);
+    assertFalse(json.contains("outdated legacy copy"), json);
+
+    // What the server makes of it.
+    IHopMetadataSerializer<RenamedType> received = 
fromJson(json).getSerializer(RenamedType.class);
+    assertEquals(List.of("both", "current-only", "legacy-only"), 
sortedNames(received));
+    assertEquals("current copy", received.load("both").getDescription());
+    assertEquals("legacy", received.load("legacy-only").getDescription());
+
+    // Exporting reads, it never moves files around in the project.
+    
assertTrue(Files.exists(folder.resolve("RenamedType").resolve("legacy-only.json")));
+    
assertTrue(Files.exists(folder.resolve("RenamedType").resolve("both.json")));
+  }
+
+  private static void write(Path typeFolder, String name, String description) 
throws Exception {
+    Files.createDirectories(typeFolder);
+    Files.write(
+        typeFolder.resolve(name + ".json"),
+        ("{\"name\":\"" + name + "\",\"description\":\"" + description + 
"\"}").getBytes(UTF_8));
+  }
+}
diff --git 
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/metadata/SparkCatalog.java
 
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/metadata/SparkCatalog.java
index 13c22a88b1..7ca4f067a7 100644
--- 
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/metadata/SparkCatalog.java
+++ 
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/metadata/SparkCatalog.java
@@ -45,7 +45,8 @@ import org.eclipse.swt.widgets.Shell;
 @Setter
 @GuiPlugin
 @HopMetadata(
-    key = "SparkCatalog",
+    key = "spark-catalog",
+    legacyKeys = {"SparkCatalog"},
     name = "i18n::SparkCatalog.Name",
     description = "i18n::SparkCatalog.Description",
     image = "spark-catalog.svg",
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitConnection.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitConnection.java
index 04e828a3bb..1d2f7d86d9 100644
--- 
a/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitConnection.java
+++ 
b/plugins/misc/git/src/main/java/org/apache/hop/git/provider/GitConnection.java
@@ -33,7 +33,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
 @Getter
 @Setter
 @HopMetadata(
-    key = "gitconnection",
+    key = "git-connection",
+    legacyKeys = {"gitconnection"},
     name = "i18n::GitConnection.name",
     description = "i18n::GitConnection.description",
     image = "git.svg",
diff --git 
a/plugins/misc/git/src/main/samples/metadata/gitconnection/github.json 
b/plugins/misc/git/src/main/samples/metadata/git-connection/github.json
similarity index 100%
rename from plugins/misc/git/src/main/samples/metadata/gitconnection/github.json
rename to plugins/misc/git/src/main/samples/metadata/git-connection/github.json
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintMetadataSelection.java
 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintMetadataSelection.java
index bbd6735503..c8acd3f853 100644
--- 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintMetadataSelection.java
+++ 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintMetadataSelection.java
@@ -76,7 +76,14 @@ final class LintMetadataSelection {
     if (annotation == null || Utils.isEmpty(annotation.key())) {
       return null;
     }
-    return resolveMetadataPath(annotation.key(), metadata.getName());
+    // An object which wasn't saved since its type was renamed still lives in 
a legacy key folder.
+    for (String key : HopMetadataUtil.getAllKeys(annotation)) {
+      String path = resolveMetadataPath(key, metadata.getName());
+      if (path != null) {
+        return path;
+      }
+    }
+    return null;
   }
 
   private static String resolveMetadataPath(String key, String name) {
diff --git 
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/HopMetadataFileLoaderLegacyKeysTest.java
 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/HopMetadataFileLoaderLegacyKeysTest.java
new file mode 100644
index 0000000000..da57f15355
--- /dev/null
+++ 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/HopMetadataFileLoaderLegacyKeysTest.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.lint;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.encryption.HopTwoWayPasswordEncoder;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.HopMetadata;
+import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.serializer.json.JsonMetadataProvider;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * A metadata file in a folder named after a legacy key of a renamed type 
(issue #5597) is still
+ * linted: the folder name resolves to the renamed type.
+ */
+class HopMetadataFileLoaderLegacyKeysTest {
+
+  /** A metadata type which was renamed from key "RenamedType" to 
"renamed-type". */
+  @Getter
+  @Setter
+  @HopMetadata(
+      name = "Renamed type",
+      key = "renamed-type",
+      legacyKeys = {"RenamedType"})
+  public static class RenamedType extends HopMetadataBase implements 
IHopMetadata {
+    @HopMetadataProperty private String description;
+
+    public RenamedType() {}
+  }
+
+  private static IHopMetadataProvider provider(Path metadataFolder) {
+    return new JsonMetadataProvider(
+        new HopTwoWayPasswordEncoder(),
+        metadataFolder.toString(),
+        Variables.getADefaultVariableSpace()) {
+      @Override
+      public <T extends IHopMetadata> List<Class<T>> getMetadataClasses() {
+        return List.of((Class<T>) (Class<?>) RenamedType.class);
+      }
+    };
+  }
+
+  private static File write(Path metadataFolder, String key, String name, 
String description)
+      throws Exception {
+    Path typeFolder = metadataFolder.resolve(key);
+    Files.createDirectories(typeFolder);
+    Path file = typeFolder.resolve(name + ".json");
+    Files.writeString(
+        file,
+        "{\"name\":\"" + name + "\",\"description\":\"" + description + "\"}",
+        StandardCharsets.UTF_8);
+    return file.toFile();
+  }
+
+  @Test
+  void fileInLegacyFolderIsLoaded(@TempDir Path dir) throws Exception {
+    Path metadataFolder = dir.resolve("metadata");
+    File file = write(metadataFolder, "RenamedType", "old", "legacy");
+
+    HopMetadataFileLoader.MetadataLoad load =
+        HopMetadataFileLoader.read(file, provider(metadataFolder));
+
+    assertFalse(load.isFailure(), load.error());
+    RenamedType loaded = assertInstanceOf(RenamedType.class, load.object());
+    assertEquals("old", loaded.getName());
+    assertEquals("legacy", loaded.getDescription());
+  }
+
+  @Test
+  void fileInCurrentFolderIsLoaded(@TempDir Path dir) throws Exception {
+    Path metadataFolder = dir.resolve("metadata");
+    write(metadataFolder, "RenamedType", "legacy-only", "legacy");
+    File file = write(metadataFolder, "renamed-type", "new", "current");
+
+    HopMetadataFileLoader.MetadataLoad load =
+        HopMetadataFileLoader.read(file, provider(metadataFolder));
+
+    RenamedType loaded = assertInstanceOf(RenamedType.class, load.object());
+    assertEquals("current", loaded.getDescription());
+  }
+}
diff --git 
a/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
 
b/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
index a698a245e9..8af71a3aa6 100644
--- 
a/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
+++ 
b/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
@@ -76,7 +76,8 @@ import org.eclipse.angus.mail.pop3.POP3SSLStore;
 @Getter
 @Setter
 @HopMetadata(
-    key = "MailServerConnection",
+    key = "mail-server-connection",
+    legacyKeys = {"MailServerConnection"},
     name = "i18n::MailServerConnection.name",
     description = "i18n::MailServerConnection.description",
     image = "mail.svg",
diff --git 
a/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
 
b/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
index 4e3f9e71b4..fccbdcdf41 100644
--- 
a/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
+++ 
b/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
@@ -60,7 +60,8 @@ import org.apache.hop.metadata.rest.client.RestOAuth2Grant;
 @Getter
 @Setter
 @HopMetadata(
-    key = "restconnection",
+    key = "rest-connection",
+    legacyKeys = {"restconnection"},
     name = "i18n::RestConnection.name",
     description = "i18n::RestConnection.description",
     image = "rest.svg",
diff --git 
a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java 
b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
index 6eb9724ec4..1ad1509fd5 100644
--- a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
+++ b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
@@ -32,7 +32,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
 @Setter
 @GuiPlugin
 @HopMetadata(
-    key = "S3ConnectionDefinition",
+    key = "s3-connection",
+    legacyKeys = {"S3ConnectionDefinition"},
     name = "i18n::S3Meta.name",
     description = "i18n::S3Meta.description",
     image = "s3.svg",
diff --git 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
index fb1dfe5be2..7c93f2c3dd 100644
--- 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
+++ 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
@@ -28,7 +28,8 @@ import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
 
 @HopMetadata(
-    key = "AzureConnectionDefinition",
+    key = "azure-connection",
+    legacyKeys = {"AzureConnectionDefinition"},
     name = "i18n::AzureMetadataType.Name",
     description = "i18n::AzureMetadataType.Description",
     image = "ui/images/authentication.svg",
diff --git 
a/plugins/tech/databricks/src/main/java/org/apache/hop/databricks/metadata/DatabricksConnection.java
 
b/plugins/tech/databricks/src/main/java/org/apache/hop/databricks/metadata/DatabricksConnection.java
index 0836f89991..879cad9040 100644
--- 
a/plugins/tech/databricks/src/main/java/org/apache/hop/databricks/metadata/DatabricksConnection.java
+++ 
b/plugins/tech/databricks/src/main/java/org/apache/hop/databricks/metadata/DatabricksConnection.java
@@ -39,7 +39,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
 @Setter
 @GuiPlugin
 @HopMetadata(
-    key = "DatabricksConnection",
+    key = "databricks-connection",
+    legacyKeys = {"DatabricksConnection"},
     name = "i18n::DatabricksConnection.Name",
     description = "i18n::DatabricksConnection.Description",
     image = "databricks-connection.svg",
diff --git 
a/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/metadata/DatabricksVfsConnection.java
 
b/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/metadata/DatabricksVfsConnection.java
index 5c2d6215ee..d87e584bc2 100644
--- 
a/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/metadata/DatabricksVfsConnection.java
+++ 
b/plugins/tech/databricks/src/main/java/org/apache/hop/vfs/databricks/metadata/DatabricksVfsConnection.java
@@ -41,7 +41,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
 @Setter
 @GuiPlugin
 @HopMetadata(
-    key = "DatabricksVfsConnectionDefinition",
+    key = "databricks-vfs-connection",
+    legacyKeys = {"DatabricksVfsConnectionDefinition"},
     name = "i18n::DatabricksVfsConnection.Name",
     description = "i18n::DatabricksVfsConnection.Description",
     image = "databricks-connection.svg",
diff --git 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
 
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
index 71586f010b..00fa23c5e9 100644
--- 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
+++ 
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
@@ -28,7 +28,8 @@ import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
 
 @HopMetadata(
-    key = "GoogleStorageConnectionDefinition",
+    key = "google-storage-connection",
+    legacyKeys = {"GoogleStorageConnectionDefinition"},
     name = "i18n::GoogleStorageMetadataType.Name",
     description = "i18n::GoogleStorageMetadataType.Description",
     image = "ui/images/authentication.svg",
diff --git 
a/plugins/tech/hadoop/src/main/java/org/apache/hop/vfs/hdfs/metadata/HdfsMeta.java
 
b/plugins/tech/hadoop/src/main/java/org/apache/hop/vfs/hdfs/metadata/HdfsMeta.java
index a156399dd1..c6bafce6a0 100644
--- 
a/plugins/tech/hadoop/src/main/java/org/apache/hop/vfs/hdfs/metadata/HdfsMeta.java
+++ 
b/plugins/tech/hadoop/src/main/java/org/apache/hop/vfs/hdfs/metadata/HdfsMeta.java
@@ -43,7 +43,8 @@ import org.eclipse.swt.widgets.MessageBox;
 @Setter
 @GuiPlugin(classLoaderGroup = "vfs-hdfs")
 @HopMetadata(
-    key = "HdfsConnectionDefinition",
+    key = "hdfs-connection",
+    legacyKeys = {"HdfsConnectionDefinition"},
     name = "i18n::HdfsMeta.Name",
     description = "i18n::HdfsMeta.Description",
     image = "hdfs.svg",
diff --git 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
index 5a0a9d7c15..45d7d15a36 100644
--- 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
+++ 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
@@ -34,7 +34,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
 @Setter
 @GuiPlugin
 @HopMetadata(
-    key = "MinioConnectionDefinition",
+    key = "minio-connection",
+    legacyKeys = {"MinioConnectionDefinition"},
     name = "i18n::MinioMeta.Name",
     description = "i18n::MinioMeta.Description",
     image = "minio.svg",
diff --git 
a/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
 
b/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
index c41252bc2a..09828ff750 100644
--- 
a/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
+++ 
b/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
@@ -29,7 +29,8 @@ import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
 
 @HopMetadata(
-    key = "salesforceconnection",
+    key = "salesforce-connection",
+    legacyKeys = {"salesforceconnection"},
     name = "i18n::SalesforceConnection.name",
     description = "i18n::SalesforceConnection.description",
     image = "salesforce.svg",
diff --git 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
index dca27634f6..8790c1463d 100644
--- 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
+++ 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
@@ -36,7 +36,8 @@ import org.apache.hop.metadata.api.IHopMetadata;
 @Getter
 @Setter
 @HopMetadata(
-    key = "WebDavConnectionDefinition",
+    key = "webdav-connection",
+    legacyKeys = {"WebDavConnectionDefinition"},
     name = "i18n::WebDavConnection.Name",
     description = "i18n::WebDavConnection.Description",
     image = "ui/images/authentication.svg",
diff --git 
a/plugins/transforms/metadata/src/main/java/org/apache/hop/pipeline/transforms/metainput/MetadataInput.java
 
b/plugins/transforms/metadata/src/main/java/org/apache/hop/pipeline/transforms/metainput/MetadataInput.java
index de68fbe3b6..37824ab278 100644
--- 
a/plugins/transforms/metadata/src/main/java/org/apache/hop/pipeline/transforms/metainput/MetadataInput.java
+++ 
b/plugins/transforms/metadata/src/main/java/org/apache/hop/pipeline/transforms/metainput/MetadataInput.java
@@ -70,7 +70,8 @@ public class MetadataInput extends 
BaseTransform<MetadataInputMeta, MetadataInpu
       //
       boolean include = typeKeyFilters.isEmpty();
       for (String typeKeyFilter : typeKeyFilters) {
-        if (typeKeyFilter.equals(annotation.key())) {
+        // A filter can still use the key the type had before it was renamed.
+        if (HopMetadataUtil.matchesKey(annotation, typeKeyFilter)) {
           include = true;
         }
       }
diff --git 
a/plugins/transforms/rest/src/main/samples/metadata/restconnection/github-releases.json
 
b/plugins/transforms/rest/src/main/samples/metadata/rest-connection/github-releases.json
similarity index 100%
rename from 
plugins/transforms/rest/src/main/samples/metadata/restconnection/github-releases.json
rename to 
plugins/transforms/rest/src/main/samples/metadata/rest-connection/github-releases.json
diff --git 
a/plugins/transforms/rest/src/main/samples/metadata/restconnection/slack-conversations.json
 
b/plugins/transforms/rest/src/main/samples/metadata/rest-connection/slack-conversations.json
similarity index 100%
rename from 
plugins/transforms/rest/src/main/samples/metadata/restconnection/slack-conversations.json
rename to 
plugins/transforms/rest/src/main/samples/metadata/rest-connection/slack-conversations.json
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
index d865eb84f2..4fb6192724 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
@@ -63,7 +63,6 @@ import 
org.apache.hop.metadata.refactor.MetadataObjectReference;
 import org.apache.hop.metadata.refactor.MetadataReferenceFinder;
 import org.apache.hop.metadata.refactor.MetadataReferenceResult;
 import org.apache.hop.metadata.serializer.json.JsonMetadataProvider;
-import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
 import org.apache.hop.metadata.util.HopMetadataUtil;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.FormDataBuilder;
@@ -2461,7 +2460,10 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
                 annotation.image(),
                 metadataClass);
 
-        knownKeys.add(annotation.key());
+        // A folder named after a legacy key holds objects of this type which 
weren't saved since
+        // the type was renamed: they are not unknown.
+        //
+        knownKeys.addAll(HopMetadataUtil.getAllKeys(annotation));
 
         IHopMetadataSerializer<IHopMetadata> serializer =
             metadataProvider.getSerializer(metadataClass);
@@ -2531,7 +2533,9 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
       IHopMetadataProvider metadataProvider,
       Set<String> knownKeys,
       Map<String, UnknownTypeModel> unknownByKey) {
-    for (JsonMetadataProvider jsonProvider : 
getJsonProviders(metadataProvider)) {
+    // Child provider first, the way load() looks for an element.
+    //
+    for (JsonMetadataProvider jsonProvider : 
getJsonProviders(metadataProvider).reversed()) {
       try {
         FileObject baseFolder = 
HopVfs.getFileObject(jsonProvider.getBaseFolder());
         if (!baseFolder.exists()) {
@@ -2552,8 +2556,8 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
               unknownByKey.computeIfAbsent(key, k -> new UnknownTypeModel(k, 
k));
           for (FileObject jsonFile : jsonFiles) {
             String name = 
jsonFile.getName().getBaseName().replaceAll("\\.json$", "");
-            // The same element can live in a parent project as well: like 
anywhere else the first
-            // provider which has it wins, so we don't list it twice.
+            // The same element can live in a parent project as well: like 
anywhere else the child
+            // project's copy wins, so we don't list it twice.
             if (unknownType.items.stream().noneMatch(item -> 
item.name.equals(name))) {
               unknownType.items.add(
                   new UnknownItemModel(name, HopVfs.getFilename(jsonFile), 
reason));
@@ -2567,25 +2571,42 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     }
   }
 
-  /** The JSON (file based) providers behind the given provider, which can be 
a multi-provider. */
+  /**
+   * The JSON (file based) providers behind the given provider, which can be a 
multi-provider: the
+   * parent project first, the child project last.
+   */
   private static List<JsonMetadataProvider> 
getJsonProviders(IHopMetadataProvider provider) {
     List<JsonMetadataProvider> jsonProviders = new ArrayList<>();
-    if (provider instanceof MultiMetadataProvider multiProvider) {
-      for (IHopMetadataProvider childProvider : multiProvider.getProviders()) {
-        jsonProviders.addAll(getJsonProviders(childProvider));
+    for (IHopMetadataProvider leaf : HopMetadataUtil.getProviders(provider)) {
+      if (leaf instanceof JsonMetadataProvider jsonProvider) {
+        jsonProviders.add(jsonProvider);
       }
-    } else if (provider instanceof JsonMetadataProvider jsonProvider) {
-      jsonProviders.add(jsonProvider);
     }
     return jsonProviders;
   }
 
   /**
-   * The file behind a metadata element: {@code <base folder>/<type 
key>/<name>.json} in the first
-   * provider which has it. Returns null if no file was found (or the metadata 
isn't file based).
+   * The file behind a metadata element, the one {@code load()} reads: in the 
last (child) provider
+   * which has it, in the folder of the current key before a legacy one. 
Returns null if no file was
+   * found (or the metadata isn't file based).
    */
   private String findMetadataFilename(String typeKey, String name) {
-    for (JsonMetadataProvider jsonProvider : 
getJsonProviders(hopGui.getMetadataProvider())) {
+    IHopMetadataProvider metadataProvider = hopGui.getMetadataProvider();
+    Class<IHopMetadata> metadataClass = null;
+    try {
+      metadataClass = metadataProvider.getMetadataClassForKey(typeKey);
+    } catch (Exception e) {
+      // An unknown type: only look in the folder named after the key, below.
+    }
+    if (metadataClass != null) {
+      try {
+        return HopMetadataUtil.findFilename(metadataProvider, metadataClass, 
name);
+      } catch (Exception e) {
+        LogChannel.UI.logError("Error looking for the file of metadata element 
" + name, e);
+        return null;
+      }
+    }
+    for (JsonMetadataProvider jsonProvider : 
getJsonProviders(metadataProvider).reversed()) {
       String filename = jsonProvider.getBaseFolder() + "/" + typeKey + "/" + 
name + ".json";
       try {
         if (HopVfs.fileExists(filename)) {
@@ -2603,29 +2624,46 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
    * its metadata type model, so explicitly-created (and possibly empty) 
folders are rendered.
    */
   private void loadPersistedFolders() {
+    // Folders can have been stored under a key the metadata type had before 
it was renamed.
+    //
     Map<String, MetadataTypeModel> byKey = new LinkedHashMap<>();
     for (MetadataTypeModel typeModel : typeModels) {
-      byKey.put(typeModel.key, typeModel);
+      HopMetadata annotation = 
HopMetadataUtil.getHopMetadataAnnotation(typeModel.metadataClass);
+      for (String key : HopMetadataUtil.getAllKeys(annotation)) {
+        byKey.putIfAbsent(key, typeModel);
+      }
     }
     try {
-      AuditList list =
-          AuditManager.getActive().retrieveList(getAuditNamespace(), 
FOLDER_AUDIT_TYPE);
+      IAuditManager auditManager = AuditManager.getActive();
+      String namespace = getAuditNamespace();
+      AuditList list = auditManager.retrieveList(namespace, FOLDER_AUDIT_TYPE);
       if (list == null || list.getNames() == null) {
         return;
       }
-      for (String entry : list.getNames()) {
+      boolean migrated = false;
+      for (int i = 0; i < list.getNames().size(); i++) {
+        String entry = list.getNames().get(i);
         int sep = entry.indexOf(FOLDER_AUDIT_SEPARATOR);
         if (sep < 0) {
           continue;
         }
-        MetadataTypeModel typeModel = byKey.get(entry.substring(0, sep));
+        String key = entry.substring(0, sep);
+        MetadataTypeModel typeModel = byKey.get(key);
         String path = entry.substring(sep + FOLDER_AUDIT_SEPARATOR.length());
+        if (typeModel != null && !typeModel.key.equals(key)) {
+          // Store it under the current key so removing the folder later on 
works.
+          list.getNames().set(i, typeModel.key + FOLDER_AUDIT_SEPARATOR + 
path);
+          migrated = true;
+        }
         if (typeModel != null
             && !Utils.isEmpty(path)
             && !typeModel.folderVirtualPaths.contains(path)) {
           typeModel.folderVirtualPaths.add(path);
         }
       }
+      if (migrated) {
+        auditManager.storeList(namespace, FOLDER_AUDIT_TYPE, list);
+      }
     } catch (Exception e) {
       LogChannel.UI.logError("Error reading metadata virtual folders from the 
audit trail", e);
     }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiMetadataSearchable.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiMetadataSearchable.java
index 7c542db465..00650185d8 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiMetadataSearchable.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiMetadataSearchable.java
@@ -23,6 +23,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.api.IHopMetadataSerializer;
 import org.apache.hop.metadata.serializer.json.JsonMetadataSerializer;
+import org.apache.hop.metadata.util.HopMetadataUtil;
 import org.apache.hop.ui.core.metadata.MetadataManager;
 import org.apache.hop.ui.hopgui.HopGui;
 
@@ -61,6 +62,17 @@ public class HopGuiMetadataSearchable implements 
ISearchable<IHopMetadata> {
 
   @Override
   public String getFilename() {
+    // The serializer is usually a multi-provider's, which knows no files. Ask 
the file based
+    // provider which holds the object: the child project before its parent, 
and a legacy folder of
+    // a renamed metadata type if that is where the object still lives.
+    try {
+      String filename = HopMetadataUtil.findFilename(metadataProvider, 
managedClass, getName());
+      if (filename != null) {
+        return filename;
+      }
+    } catch (Exception e) {
+      // Fall back to where the object would be saved.
+    }
     if (serializer instanceof JsonMetadataSerializer jsonMetadataSerializer) {
       return jsonMetadataSerializer.calculateFilename(getName());
     }
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/search/HopGuiMetadataSearchableTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/search/HopGuiMetadataSearchableTest.java
new file mode 100644
index 0000000000..87eef0f293
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/search/HopGuiMetadataSearchableTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.search;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.encryption.HopTwoWayPasswordEncoder;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.metadata.api.HopMetadata;
+import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.api.IHopMetadataSerializer;
+import org.apache.hop.metadata.serializer.json.JsonMetadataProvider;
+import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * A metadata search result points at the file of the object. Project search 
hands it a
+ * multi-provider's serializer, which knows no files: the file has to come 
from the provider which
+ * holds the object (issue #5597).
+ */
+class HopGuiMetadataSearchableTest {
+
+  /** A metadata type which was renamed: its objects can still be in the 
SearchType folder. */
+  @HopMetadata(name = "Search type", key = "search-type", legacyKeys = 
"SearchType")
+  public static class SearchType extends HopMetadataBase implements 
IHopMetadata {
+    public SearchType() {
+      // For the serializer.
+    }
+  }
+
+  @TempDir Path parentFolder;
+  @TempDir Path childFolder;
+
+  @Test
+  @SuppressWarnings("unchecked")
+  void theFileOfAnObjectFoundThroughAMultiProvider() throws Exception {
+    // The parent migrated to the current key, the child still has the object 
in the legacy folder
+    // and wins, the way load() resolves it.
+    write(parentFolder, "search-type", "api");
+    write(childFolder, "SearchType", "api");
+
+    HopTwoWayPasswordEncoder encoder = new HopTwoWayPasswordEncoder();
+    MultiMetadataProvider multi =
+        new MultiMetadataProvider(
+            encoder,
+            new ArrayList<IHopMetadataProvider>(
+                List.of(
+                    new JsonMetadataProvider(
+                        encoder, parentFolder.toString(), 
Variables.getADefaultVariableSpace()),
+                    new JsonMetadataProvider(
+                        encoder, childFolder.toString(), 
Variables.getADefaultVariableSpace()))),
+            Variables.getADefaultVariableSpace());
+    IHopMetadataSerializer<IHopMetadata> serializer =
+        (IHopMetadataSerializer<IHopMetadata>)
+            (IHopMetadataSerializer<?>) multi.getSerializer(SearchType.class);
+
+    HopGuiMetadataSearchable searchable =
+        new HopGuiMetadataSearchable(
+            multi,
+            serializer,
+            serializer.load("api"),
+            (Class<IHopMetadata>) (Class<?>) SearchType.class);
+
+    assertEquals(
+        childFolder.resolve("SearchType").resolve("api.json").toRealPath(),
+        
Path.of(HopVfs.getFileObject(searchable.getFilename()).getURL().toURI()).toRealPath());
+  }
+
+  private static void write(Path metadataFolder, String key, String name) 
throws Exception {
+    Path typeFolder = metadataFolder.resolve(key);
+    Files.createDirectories(typeFolder);
+    Files.write(
+        typeFolder.resolve(name + ".json"), ("{\"name\":\"" + name + 
"\"}").getBytes(UTF_8));
+  }
+}

Reply via email to