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

asf-gitbox-commits pushed a commit to branch UNOMI-997-migration-tenants
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/UNOMI-997-migration-tenants by 
this push:
     new 026c6502a UNOMI-997: Address review findings on tenant assignment
026c6502a is described below

commit 026c6502a4ac31cf2fbb9ae9000f05fe92bef41f
Author: Serge Huber <[email protected]>
AuthorDate: Wed Oct 7 09:51:32 2026 +0200

    UNOMI-997: Address review findings on tenant assignment
    
    Keep the creation date and version, not only the creator, when a bundle
    redeploys a migrated definition. Do not add the _itemtype suffix twice
    to system item ids that already carry it from 3.0.
    
    Make the Painless script the only tenant decision and drop the unused
    Java copy. Group shipped ids by item type, reject an empty or malformed
    list, add the shipped personas, and add a test that fails when the list
    no longer matches the definitions in the source tree.
    
    Fail the id-fix step when an item type fails, stop on conflicts, and
    count only documents that changed. Cover the missing cases in the
    tenant assignment integration test.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../itests/migration/Migrate40IndexMappingIT.java  |   2 +-
 .../migration/Migrate40TenantAssignmentIT.java     | 325 ++++++++++++---------
 .../cache/AbstractMultiTypeCachingService.java     |  19 +-
 .../cache/AbstractMultiTypeCachingServiceTest.java |  27 +-
 .../shell/migration/utils/MigrationUtils.java      |  95 +++---
 .../migrate-4.0.0-01-tenantDocumentIds.groovy      |   6 +-
 .../migrate-4.0.0-05-fixSystemItemIds.groovy       |  29 +-
 .../requestBody/4.0.0/fix_system_item_ids.painless |  37 +--
 .../initialize_tenant_and_audit_fields.painless    |  42 +--
 .../4.0.0/shipped_system_definitions.json          |   4 +
 .../shell/migration/utils/MigrationUtilsTest.java  | 144 ++++++---
 11 files changed, 399 insertions(+), 331 deletions(-)

diff --git 
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
 
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
index dc2f0ea70..04fba0729 100644
--- 
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
+++ 
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
@@ -155,7 +155,7 @@ public class Migrate40IndexMappingIT extends BaseIT {
             params.put("itemType", itemType);
             params.put("date", "2026-01-01T00:00:00Z");
             params.put("systemItems", Collections.emptySet());
-            params.put("shippedDefinitionIds", Collections.emptySet());
+            params.put("shippedDefinitionIds", Collections.emptyMap());
 
             String painless = MigrationUtils.getFileWithoutComments(
                     migrationBundles, 
"requestBody/4.0.0/initialize_tenant_and_audit_fields.painless");
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40TenantAssignmentIT.java
 
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40TenantAssignmentIT.java
index 16d24c8f8..4c466756d 100644
--- 
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40TenantAssignmentIT.java
+++ 
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40TenantAssignmentIT.java
@@ -33,7 +33,6 @@ import org.osgi.framework.FrameworkUtil;
 
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 
@@ -44,10 +43,24 @@ import java.util.Set;
 @Category(SearchBackendIT.class)
 public class Migrate40TenantAssignmentIT extends BaseIT {
 
-    private static final String SOURCE_INDEX = "context-unomi997systemitems";
-    private static final String DEST_INDEX = 
"context-unomi997systemitems-dest";
-    private static final String GEO_SOURCE = "context-unomi997geonameentry";
-    private static final String GEO_DEST = "context-unomi997geonameentry-dest";
+    private static final String SOURCE_INDEX = 
"context-unomi997source-systemitems";
+    private static final String DEST_INDEX = 
"context-unomi997dest-systemitems";
+    private static final String GEO_SOURCE = 
"context-unomi997source-geonameentry";
+    private static final String GEO_DEST = "context-unomi997dest-geonameentry";
+    private static final String DATA_SOURCE = "context-unomi997source-profile";
+    private static final String DATA_DEST = "context-unomi997dest-profile";
+    private static final String CAMEL_CASE_INDEX = "context-unomi997camelcase";
+
+    private static final String MAPPING = "{"
+            + "\"settings\":{\"number_of_shards\":1,\"number_of_replicas\":0},"
+            + "\"mappings\":{\"properties\":{"
+            + "\"itemId\":{\"type\":\"keyword\"},"
+            + "\"itemType\":{\"type\":\"keyword\"},"
+            + "\"scope\":{\"type\":\"keyword\"},"
+            + "\"tenantId\":{\"type\":\"keyword\"}"
+            + "}}}";
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
 
     @After
     public void deleteTestIndices() throws Exception {
@@ -56,187 +69,207 @@ public class Migrate40TenantAssignmentIT extends BaseIT {
         }
         try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
             String es = getSearchEngineBaseUrl();
-            MigrationUtils.deleteIndex(httpClient, es, SOURCE_INDEX);
-            MigrationUtils.deleteIndex(httpClient, es, DEST_INDEX);
-            MigrationUtils.deleteIndex(httpClient, es, GEO_SOURCE);
-            MigrationUtils.deleteIndex(httpClient, es, GEO_DEST);
+            for (String index : new String[]{SOURCE_INDEX, DEST_INDEX, 
GEO_SOURCE, GEO_DEST, DATA_SOURCE, DATA_DEST, CAMEL_CASE_INDEX}) {
+                MigrationUtils.deleteIndex(httpClient, es, index);
+            }
         }
     }
 
     @Test
-    public void shippedDefinitionsListIsPresentInTheMigrationBundle() throws 
Exception {
-        Set<String> shipped = MigrationUtils.loadShippedDefinitionIds(
-                
FrameworkUtil.getBundle(MigrationUtils.class).getBundleContext());
-        Assert.assertTrue(shipped.contains("conditiontype:booleancondition"));
-        
Assert.assertTrue(shipped.contains("conditiontype:pasteventcondition"));
-        
Assert.assertTrue(shipped.contains("conditiontype:profilepropertycondition"));
+    public void shippedDefinitionsListIsPresentInTheMigrationBundle() {
+        Map<String, Set<String>> shipped = 
MigrationUtils.loadShippedDefinitionIds(migrationBundle());
+        
Assert.assertTrue(shipped.get("conditiontype").contains("booleancondition"));
+        
Assert.assertTrue(shipped.get("conditiontype").contains("pasteventcondition"));
+        
Assert.assertTrue(shipped.get("conditiontype").contains("profilepropertycondition"));
         Assert.assertFalse("user content must not be in the shipped list by id 
alone",
-                shipped.contains("rule:my-user-rule"));
+                shipped.get("rule").contains("my-user-rule"));
     }
 
     @Test
     public void migrationPutsEachKindOfItemInTheRightTenant() throws Exception 
{
-        Assume.assumeTrue(
-                "HTTP admin API required (provider=" + 
getPersistenceBackend().providerId() + ")",
-                persistenceCapabilities().httpAdminApi());
+        assumeHttpAdminApi();
 
         try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
             String es = getSearchEngineBaseUrl();
-            BundleContext migrationBundles = 
FrameworkUtil.getBundle(MigrationUtils.class).getBundleContext();
-            Set<String> shipped = 
MigrationUtils.loadShippedDefinitionIds(migrationBundles);
+            HttpUtils.executePutRequest(httpClient, es + "/" + SOURCE_INDEX, 
MAPPING, null);
+            // Shipped definitions: without scope, with the 3.0 default scope, 
and with the 3.0 _itemtype id suffix
+            indexDocument(httpClient, SOURCE_INDEX, "booleanCondition", 
"booleanCondition", "conditionType", null);
+            indexDocument(httpClient, SOURCE_INDEX, "updateProperties", 
"updateProperties", "rule", "systemscope");
+            indexDocument(httpClient, SOURCE_INDEX, 
"pastEventCondition_conditiontype", "pastEventCondition_conditiontype", 
"conditionType", null);
+            // User content with the 3.0 default scope
+            indexDocument(httpClient, SOURCE_INDEX, "my-user-rule", 
"my-user-rule", "rule", "systemscope");
+            indexDocument(httpClient, SOURCE_INDEX, "my-segment", 
"my-segment", "segment", "systemscope");
+            indexDocument(httpClient, SOURCE_INDEX, "my-scoring", 
"my-scoring", "scoring", "systemscope");
+            indexDocument(httpClient, SOURCE_INDEX, "my-goal_goal", "my-goal", 
"goal", "systemscope");
+            // Literal system scope on a type that is neither shipped nor a 
system item type
+            indexDocument(httpClient, SOURCE_INDEX, "cfg1", "cfg1", 
"sfdcConfiguration", "system");
 
-            String mapping = "{"
-                    + 
"\"settings\":{\"number_of_shards\":1,\"number_of_replicas\":0},"
-                    + "\"mappings\":{\"properties\":{"
-                    + "\"itemId\":{\"type\":\"keyword\"},"
-                    + "\"itemType\":{\"type\":\"keyword\"},"
-                    + "\"scope\":{\"type\":\"keyword\"},"
-                    + "\"tenantId\":{\"type\":\"keyword\"}"
-                    + "}}}";
-            HttpUtils.executePutRequest(httpClient, es + "/" + SOURCE_INDEX, 
mapping, null);
-            HttpUtils.executePutRequest(httpClient, es + "/" + SOURCE_INDEX
-                    + "/_doc/booleanCondition?refresh=true",
-                    
"{\"itemId\":\"booleanCondition\",\"itemType\":\"conditionType\"}", null);
-            HttpUtils.executePutRequest(httpClient, es + "/" + SOURCE_INDEX
-                    + "/_doc/my-user-rule?refresh=true",
-                    
"{\"itemId\":\"my-user-rule\",\"itemType\":\"rule\",\"scope\":\"systemscope\"}",
 null);
-            HttpUtils.executePutRequest(httpClient, es + "/" + SOURCE_INDEX
-                    + "/_doc/my-segment?refresh=true",
-                    
"{\"itemId\":\"my-segment\",\"itemType\":\"segment\",\"scope\":\"systemscope\"}",
 null);
-            HttpUtils.executePutRequest(httpClient, es + "/" + SOURCE_INDEX
-                    + "/_doc/my-scoring?refresh=true",
-                    
"{\"itemId\":\"my-scoring\",\"itemType\":\"scoring\",\"scope\":\"systemscope\"}",
 null);
-            HttpUtils.executePutRequest(httpClient, es + "/" + SOURCE_INDEX
-                    + "/_doc/my-goal?refresh=true",
-                    
"{\"itemId\":\"my-goal\",\"itemType\":\"goal\",\"scope\":\"systemscope\"}", 
null);
-
-            MigrationUtils.createIndex(httpClient, es, DEST_INDEX, mapping);
-
-            Set<String> systemItems = new HashSet<>();
-            Collections.addAll(systemItems, "conditiontype", "rule", 
"segment", "scoring", "goal");
-
-            Map<String, Object> params = new HashMap<>();
-            params.put("tenantId", TEST_TENANT_ID);
-            params.put("systemTenantId", "system");
-            params.put("forceDefaultTenant", false);
-            params.put("itemType", "systemitems");
-            params.put("date", "2026-01-01T00:00:00Z");
-            params.put("systemItems", systemItems);
-            params.put("shippedDefinitionIds", shipped);
-
-            String painless = MigrationUtils.getFileWithoutComments(
-                    migrationBundles, 
"requestBody/4.0.0/initialize_tenant_and_audit_fields.painless");
-            MigrationUtils.moveToIndex(httpClient, migrationBundles, es, 
SOURCE_INDEX, DEST_INDEX, painless, params);
-            HttpUtils.executePostRequest(httpClient, es + "/" + DEST_INDEX + 
"/_refresh", null, null);
-
-            JsonNode hits = new 
ObjectMapper().readTree(HttpUtils.executeGetRequest(httpClient,
-                    es + "/" + DEST_INDEX + "/_search?size=20", 
null)).path("hits").path("hits");
-            Assert.assertEquals(5, hits.size());
-
-            Map<String, JsonNode> byItemId = new HashMap<>();
-            for (JsonNode hit : hits) {
-                byItemId.put(hit.path("_source").path("itemId").asText(), hit);
-            }
+            Map<String, JsonNode> byItemId = migrate(httpClient, SOURCE_INDEX, 
DEST_INDEX, "systemitems", false,
+                    Set.of("conditiontype", "rule", "segment", "scoring", 
"goal"),
+                    
MigrationUtils.loadShippedDefinitionIds(migrationBundle()));
+            Assert.assertEquals(8, byItemId.size());
 
             assertTenant(byItemId.get("booleanCondition"), "system");
+            assertTenant(byItemId.get("updateProperties"), "system");
+            assertTenant(byItemId.get("pastEventCondition_conditiontype"), 
"system");
             assertTenant(byItemId.get("my-user-rule"), TEST_TENANT_ID);
             assertTenant(byItemId.get("my-segment"), TEST_TENANT_ID);
             assertTenant(byItemId.get("my-scoring"), TEST_TENANT_ID);
             assertTenant(byItemId.get("my-goal"), TEST_TENANT_ID);
-            Assert.assertFalse("no user-created item may land in system",
-                    
byItemId.get("my-user-rule").path("_id").asText().startsWith("system_"));
+            assertTenant(byItemId.get("cfg1"), "system");
+
+            // System item types get the _itemtype suffix exactly once, other 
types none
+            Assert.assertEquals("system_booleanCondition_conditiontype", 
byItemId.get("booleanCondition").path("_id").asText());
+            Assert.assertEquals("system_pastEventCondition_conditiontype",
+                    
byItemId.get("pastEventCondition_conditiontype").path("_id").asText());
+            Assert.assertEquals(TEST_TENANT_ID + "_my-goal_goal", 
byItemId.get("my-goal").path("_id").asText());
+            Assert.assertEquals("system_cfg1", 
byItemId.get("cfg1").path("_id").asText());
+
+            // Migrated system items carry create audit, so the bundle 
redeploy is an update
+            JsonNode source = byItemId.get("booleanCondition").path("_source");
+            Assert.assertEquals("system-migration-4.0.0", 
source.path("createdBy").asText());
+            Assert.assertEquals("system-migration-4.0.0", 
source.path("lastModifiedBy").asText());
+            Assert.assertEquals("2026-01-01T00:00:00Z", 
source.path("creationDate").asText());
+        }
+    }
+
+    @Test
+    public void forcedDefaultTenantNeverUsesTheSystemTenant() throws Exception 
{
+        assumeHttpAdminApi();
+
+        try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+            String es = getSearchEngineBaseUrl();
+            HttpUtils.executePutRequest(httpClient, es + "/" + SOURCE_INDEX, 
MAPPING, null);
+            indexDocument(httpClient, SOURCE_INDEX, "node1", "node1", 
"clusterNode", "system");
+            indexDocument(httpClient, SOURCE_INDEX, "booleanCondition", 
"booleanCondition", "conditionType", null);
+
+            Map<String, JsonNode> byItemId = migrate(httpClient, SOURCE_INDEX, 
DEST_INDEX, "clusterNode", true,
+                    Collections.emptySet(), 
MigrationUtils.loadShippedDefinitionIds(migrationBundle()));
+            Assert.assertEquals(2, byItemId.size());
+            assertTenant(byItemId.get("node1"), TEST_TENANT_ID);
+            assertTenant(byItemId.get("booleanCondition"), TEST_TENANT_ID);
+        }
+    }
+
+    @Test
+    public void profilesSessionsAndEventsStayInTheConfiguredTenant() throws 
Exception {
+        assumeHttpAdminApi();
+
+        try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+            String es = getSearchEngineBaseUrl();
+            HttpUtils.executePutRequest(httpClient, es + "/" + DATA_SOURCE, 
MAPPING, null);
+            indexDocument(httpClient, DATA_SOURCE, "p1", "p1", "profile", 
null);
+            indexDocument(httpClient, DATA_SOURCE, "s1", "s1", "session", 
"systemscope");
+            indexDocument(httpClient, DATA_SOURCE, "e1", "e1", "event", 
"systemscope");
+
+            Map<String, JsonNode> byItemId = migrate(httpClient, DATA_SOURCE, 
DATA_DEST, "profile", false,
+                    Set.of("conditiontype", "rule"), 
MigrationUtils.loadShippedDefinitionIds(migrationBundle()));
+            Assert.assertEquals(3, byItemId.size());
+            for (String itemId : new String[]{"p1", "s1", "e1"}) {
+                assertTenant(byItemId.get(itemId), TEST_TENANT_ID);
+                Assert.assertEquals(TEST_TENANT_ID + "_" + itemId, 
byItemId.get(itemId).path("_id").asText());
+            }
         }
     }
 
     @Test
     public void geonamesGoToTheSystemTenant() throws Exception {
-        Assume.assumeTrue(
-                "HTTP admin API required (provider=" + 
getPersistenceBackend().providerId() + ")",
-                persistenceCapabilities().httpAdminApi());
+        assumeHttpAdminApi();
 
         try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
             String es = getSearchEngineBaseUrl();
-            BundleContext migrationBundles = 
FrameworkUtil.getBundle(MigrationUtils.class).getBundleContext();
+            HttpUtils.executePutRequest(httpClient, es + "/" + GEO_SOURCE, 
MAPPING, null);
+            indexDocument(httpClient, GEO_SOURCE, "paris", "paris", 
"geonameEntry", null);
+
+            Map<String, JsonNode> byItemId = migrate(httpClient, GEO_SOURCE, 
GEO_DEST, "geonameentry", false,
+                    Collections.emptySet(), Collections.emptyMap());
+            assertTenant(byItemId.get("paris"), "system");
+        }
+    }
+
+    @Test
+    public void 
itemIdFixMatchesCamelCaseItemTypesAndCountsOnlyChangedDocuments() throws 
Exception {
+        assumeHttpAdminApi();
 
+        try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+            String es = getSearchEngineBaseUrl();
             String mapping = "{"
-                    + 
"\"settings\":{\"number_of_shards\":1,\"number_of_replicas\":0},"
+                    + "\"settings\":{"
+                    + "\"number_of_shards\":1,\"number_of_replicas\":0,"
+                    + 
"\"analysis\":{\"analyzer\":{\"folding\":{\"type\":\"custom\",\"tokenizer\":\"keyword\",\"filter\":[\"lowercase\",\"asciifolding\"]}}}"
+                    + "},"
                     + "\"mappings\":{\"properties\":{"
                     + "\"itemId\":{\"type\":\"keyword\"},"
-                    + "\"itemType\":{\"type\":\"keyword\"},"
-                    + "\"tenantId\":{\"type\":\"keyword\"}"
+                    + 
"\"itemType\":{\"type\":\"text\",\"analyzer\":\"folding\",\"fields\":{\"keyword\":{\"type\":\"keyword\",\"ignore_above\":256}}}"
                     + "}}}";
-            HttpUtils.executePutRequest(httpClient, es + "/" + GEO_SOURCE, 
mapping, null);
-            HttpUtils.executePutRequest(httpClient, es + "/" + GEO_SOURCE
-                    + "/_doc/paris?refresh=true",
-                    "{\"itemId\":\"paris\",\"itemType\":\"geonameEntry\"}", 
null);
-            MigrationUtils.createIndex(httpClient, es, GEO_DEST, mapping);
-
-            Map<String, Object> params = new HashMap<>();
-            params.put("tenantId", TEST_TENANT_ID);
-            params.put("systemTenantId", "system");
-            params.put("forceDefaultTenant", false);
-            params.put("itemType", "geonameentry");
-            params.put("date", "2026-01-01T00:00:00Z");
-            params.put("systemItems", Collections.emptySet());
-            params.put("shippedDefinitionIds", Collections.emptySet());
-
-            String painless = MigrationUtils.getFileWithoutComments(
-                    migrationBundles, 
"requestBody/4.0.0/initialize_tenant_and_audit_fields.painless");
-            MigrationUtils.moveToIndex(httpClient, migrationBundles, es, 
GEO_SOURCE, GEO_DEST, painless, params);
-            HttpUtils.executePostRequest(httpClient, es + "/" + GEO_DEST + 
"/_refresh", null, null);
-
-            JsonNode hit = new 
ObjectMapper().readTree(HttpUtils.executeGetRequest(httpClient,
-                    es + "/" + GEO_DEST + "/_search?size=1", 
null)).path("hits").path("hits").get(0);
-            assertTenant(hit, "system");
+            HttpUtils.executePutRequest(httpClient, es + "/" + 
CAMEL_CASE_INDEX, mapping, null);
+            indexDocument(httpClient, CAMEL_CASE_INDEX, 
"system_booleanCondition_conditiontype", "wrong", "conditionType", null);
+            // Already consistent: must be left untouched and not counted
+            indexDocument(httpClient, CAMEL_CASE_INDEX, 
"system_pastEventCondition_conditiontype",
+                    "pastEventCondition_conditiontype", "conditionType", null);
+
+            // Same request as migrate-4.0.0-05-fixSystemItemIds.groovy
+            String fixScript = MigrationUtils.getFileWithoutComments(
+                    migrationBundle(), 
"requestBody/4.0.0/fix_system_item_ids.painless");
+            long updated = MigrationUtils.updateByQueryAndCount(httpClient, 
es, CAMEL_CASE_INDEX,
+                    MigrationUtils.buildItemTypeUpdateRequest(fixScript, 
"conditionType"));
+            Assert.assertEquals(1L, updated);
+
+            JsonNode source = 
MAPPER.readTree(HttpUtils.executeGetRequest(httpClient,
+                    es + "/" + CAMEL_CASE_INDEX + 
"/_doc/system_booleanCondition_conditiontype", null))
+                    .path("_source");
+            Assert.assertEquals("booleanCondition_conditiontype", 
source.path("itemId").asText());
         }
     }
 
-    @Test
-    public void camelCaseItemTypeKeywordQueryUpdatesDocuments() throws 
Exception {
+    private void assumeHttpAdminApi() {
         Assume.assumeTrue(
                 "HTTP admin API required (provider=" + 
getPersistenceBackend().providerId() + ")",
                 persistenceCapabilities().httpAdminApi());
+    }
 
-        try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
-            String es = getSearchEngineBaseUrl();
-            BundleContext migrationBundles = 
FrameworkUtil.getBundle(MigrationUtils.class).getBundleContext();
-
-            String index = "context-unomi997camelcase";
-            try {
-                String mapping = "{"
-                        + "\"settings\":{"
-                        + "\"number_of_shards\":1,\"number_of_replicas\":0,"
-                        + 
"\"analysis\":{\"analyzer\":{\"folding\":{\"type\":\"custom\",\"tokenizer\":\"keyword\",\"filter\":[\"lowercase\",\"asciifolding\"]}}}"
-                        + "},"
-                        + "\"mappings\":{\"properties\":{"
-                        + "\"itemId\":{\"type\":\"keyword\"},"
-                        + 
"\"itemType\":{\"type\":\"text\",\"analyzer\":\"folding\",\"fields\":{\"keyword\":{\"type\":\"keyword\",\"ignore_above\":256}}}"
-                        + "}}}";
-                HttpUtils.executePutRequest(httpClient, es + "/" + index, 
mapping, null);
-                HttpUtils.executePutRequest(httpClient, es + "/" + index
-                        + 
"/_doc/system_booleanCondition_conditiontype?refresh=true",
-                        
"{\"itemId\":\"wrong\",\"itemType\":\"conditionType\"}", null);
-
-                String fixScript = MigrationUtils.getFileWithoutComments(
-                        migrationBundles, 
"requestBody/4.0.0/fix_system_item_ids.painless");
-                String body = "{\"script\":{\"source\":"
-                        + new ObjectMapper().writeValueAsString(fixScript)
-                        + ",\"lang\":\"painless\"},"
-                        + "\"query\":{\"bool\":{\"should\":["
-                        + 
"{\"term\":{\"itemType.keyword\":\"conditionType\"}},"
-                        + "{\"term\":{\"itemType\":\"conditiontype\"}}"
-                        + "],\"minimum_should_match\":1}}}";
-                long updated = 
MigrationUtils.updateByQueryAndCount(httpClient, es, index, body);
-                Assert.assertEquals(1L, updated);
-
-                JsonNode source = new 
ObjectMapper().readTree(HttpUtils.executeGetRequest(httpClient,
-                        es + "/" + index + 
"/_doc/system_booleanCondition_conditiontype", null))
-                        .path("_source");
-                Assert.assertEquals("booleanCondition_conditiontype", 
source.path("itemId").asText());
-            } finally {
-                MigrationUtils.deleteIndex(httpClient, es, index);
-            }
+    private static BundleContext migrationBundle() {
+        return 
FrameworkUtil.getBundle(MigrationUtils.class).getBundleContext();
+    }
+
+    private void indexDocument(CloseableHttpClient httpClient, String index, 
String documentId, String itemId,
+                               String itemType, String scope) throws Exception 
{
+        String source = "{\"itemId\":\"" + itemId + "\",\"itemType\":\"" + 
itemType + "\""
+                + (scope != null ? ",\"scope\":\"" + scope + "\"" : "") + "}";
+        HttpUtils.executePutRequest(httpClient,
+                getSearchEngineBaseUrl() + "/" + index + "/_doc/" + documentId 
+ "?refresh=true", source, null);
+    }
+
+    /**
+     * Runs the tenant assignment script from {@code source} to {@code dest}, 
as migrate-4.0.0-01 does,
+     * and returns the migrated hits by source itemId.
+     */
+    private Map<String, JsonNode> migrate(CloseableHttpClient httpClient, 
String source, String dest, String itemType,
+                                          boolean forceDefaultTenant, 
Set<String> systemItems,
+                                          Map<String, Set<String>> 
shippedDefinitionIds) throws Exception {
+        String es = getSearchEngineBaseUrl();
+        MigrationUtils.createIndex(httpClient, es, dest, MAPPING);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("tenantId", TEST_TENANT_ID);
+        params.put("systemTenantId", "system");
+        params.put("forceDefaultTenant", forceDefaultTenant);
+        params.put("itemType", itemType);
+        params.put("date", "2026-01-01T00:00:00Z");
+        params.put("systemItems", systemItems);
+        params.put("shippedDefinitionIds", shippedDefinitionIds);
+
+        String painless = MigrationUtils.getFileWithoutComments(
+                migrationBundle(), 
"requestBody/4.0.0/initialize_tenant_and_audit_fields.painless");
+        MigrationUtils.moveToIndex(httpClient, migrationBundle(), es, source, 
dest, painless, params);
+        HttpUtils.executePostRequest(httpClient, es + "/" + dest + 
"/_refresh", null, null);
+
+        Map<String, JsonNode> byItemId = new HashMap<>();
+        for (JsonNode hit : 
MAPPER.readTree(HttpUtils.executeGetRequest(httpClient,
+                es + "/" + dest + "/_search?size=20", 
null)).path("hits").path("hits")) {
+            byItemId.put(hit.path("_source").path("itemId").asText(), hit);
         }
+        return byItemId;
     }
 
     private static void assertTenant(JsonNode hit, String expectedTenant) {
diff --git 
a/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
 
b/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
index 427298a62..8cf444157 100644
--- 
a/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
+++ 
b/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
@@ -932,17 +932,14 @@ public abstract class AbstractMultiTypeCachingService 
extends AbstractContextAwa
         Class<T> itemClass = (Class<T>) item.getClass();
         T existingItem = persistenceService.load(itemId, itemClass);
 
-        // Bundle JSON definitions have no create audit. If persistence 
already has create
-        // fields (e.g. from migration), copy them onto the incoming item 
before save so a
-        // later auditUpdate does not wipe them with nulls (UNOMI-997).
-        if (existingItem != null) {
-            if (item.getCreatedBy() == null && existingItem.getCreatedBy() != 
null) {
-                item.setCreatedBy(existingItem.getCreatedBy());
-            }
-            if (item.getCreationDate() == null && 
existingItem.getCreationDate() != null) {
-                item.setCreationDate(existingItem.getCreationDate());
-            }
-            if (item.getVersion() == null && existingItem.getVersion() != 
null) {
+        // Bundle JSON definitions have no creator, and Item() defaults their 
creation date and
+        // version to "now" and 0. If persistence already has create audit 
(e.g. from migration),
+        // carry it over to the incoming item so the save below does not 
overwrite it (UNOMI-997).
+        if (existingItem != null && item.getCreatedBy() == null
+                && existingItem.getCreatedBy() != null && 
existingItem.getCreationDate() != null) {
+            item.setCreatedBy(existingItem.getCreatedBy());
+            item.setCreationDate(existingItem.getCreationDate());
+            if (existingItem.getVersion() != null) {
                 item.setVersion(existingItem.getVersion());
             }
         }
diff --git 
a/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
 
b/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
index 174609ad4..ad962c909 100644
--- 
a/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
+++ 
b/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
@@ -474,8 +474,9 @@ public class AbstractMultiTypeCachingServiceTest {
     }
 
     /**
-     * Bundle redeploy loads JSON without create audit. If persistence already 
has create fields
-     * (for example after migration), saveItem must keep them instead of 
overwriting with nulls.
+     * Bundle redeploy loads JSON without a creator, with the creation date 
and version defaulted by
+     * {@code Item()}. If persistence already has create audit (for example 
after migration),
+     * saveItem must keep it instead of overwriting it with those defaults.
      */
     @Test
     public void testSaveItemPreservesMigratedCreateAuditOnBundleRedeploy() {
@@ -487,9 +488,8 @@ public class AbstractMultiTypeCachingServiceTest {
         when(persistenceService.load("item1", 
TestItem.class)).thenReturn(existing);
 
         TestItem fromBundle = new TestItem("item1");
-        // Simulate a bundle JSON definition that has no create audit (Item 
may default dates).
-        fromBundle.setCreatedBy(null);
-        fromBundle.setCreationDate(null);
+        assertNull(fromBundle.getCreatedBy());
+        assertNotNull("Item() defaults the creation date", 
fromBundle.getCreationDate());
 
         testCachingService.callSaveItem(fromBundle);
 
@@ -497,7 +497,24 @@ public class AbstractMultiTypeCachingServiceTest {
         verify(persistenceService).save(saved.capture());
         assertEquals("system-migration-4.0.0", 
saved.getValue().getCreatedBy());
         assertEquals(creationDate, saved.getValue().getCreationDate());
+        assertEquals(Long.valueOf(3L), saved.getValue().getVersion());
         verify(auditService).auditUpdate(same(fromBundle), 
eq("system-bundle"));
         verify(auditService, never()).auditCreate(any(), anyString());
     }
+
+    /**
+     * A persisted item without complete create audit is still audited as a 
creation.
+     */
+    @Test
+    public void testSaveItemAuditsCreateWhenExistingItemHasNoCreateAudit() {
+        TestItem existing = new TestItem("item1");
+        existing.setCreatedBy(null);
+        when(persistenceService.load("item1", 
TestItem.class)).thenReturn(existing);
+
+        TestItem fromBundle = new TestItem("item1");
+        testCachingService.callSaveItem(fromBundle);
+
+        verify(auditService).auditCreate(same(fromBundle), 
eq("system-bundle"));
+        verify(auditService, never()).auditUpdate(any(), anyString());
+    }
 }
diff --git 
a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
 
b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
index fbee87829..5ab1a8bc0 100644
--- 
a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
+++ 
b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
@@ -366,75 +366,62 @@ public class MigrationUtils {
     }
 
     /**
-     * Loads the set of definitions Unomi ships and reloads at startup.
-     * Keys are {@code itemType:itemId} in lower case (for example {@code 
conditiontype:booleanCondition}).
+     * Loads the definitions Unomi ships and reloads at startup, as lower-case 
item ids grouped by
+     * lower-case item type (for example {@code conditiontype -> 
booleancondition}).
      * The migrate command bundle ships a snapshot of that list so it is 
available before plugins start.
+     * <p>
+     * Matching is by type and id only: a 3.0 item that shares both with a 
shipped definition (such as a
+     * built-in rule edited in place) is migrated to the system tenant and 
replaced by the 4.0 version
+     * at the next startup.
+     *
+     * @throws IllegalStateException if the list is empty or an entry is not 
{@code itemType:itemId}
      */
-    public static Set<String> loadShippedDefinitionIds(BundleContext 
bundleContext) throws IOException {
-        Set<String> ids = new LinkedHashSet<>();
-        String resource = resourceAsString(bundleContext, 
"requestBody/4.0.0/shipped_system_definitions.json");
-        JSONArray array = new JSONArray(resource);
+    public static Map<String, Set<String>> 
loadShippedDefinitionIds(BundleContext bundleContext) {
+        String resource = "requestBody/4.0.0/shipped_system_definitions.json";
+        JSONArray array = new JSONArray(resourceAsString(bundleContext, 
resource));
+        Map<String, Set<String>> idsByType = new TreeMap<>();
         for (int i = 0; i < array.length(); i++) {
-            String key = array.optString(i, null);
-            if (key != null && !key.isBlank()) {
-                ids.add(key.toLowerCase(Locale.ROOT));
+            String key = String.valueOf(array.get(i)).toLowerCase(Locale.ROOT);
+            int separator = key.indexOf(':');
+            if (separator <= 0 || separator == key.length() - 1) {
+                throw new IllegalStateException("Invalid entry in " + resource 
+ ": " + key);
             }
+            idsByType.computeIfAbsent(key.substring(0, separator), type -> new 
TreeSet<>()).add(key.substring(separator + 1));
         }
-        return ids;
-    }
-
-    /**
-     * Strips a trailing {@code _itemType} suffix (any case) from a document 
or item id.
-     */
-    public static String resolveBaseItemId(String documentOrItemId, String 
itemType) {
-        if (documentOrItemId == null || documentOrItemId.isBlank()) {
-            return documentOrItemId;
-        }
-        if (itemType == null || itemType.isBlank()) {
-            return documentOrItemId;
-        }
-        String suffix = "_" + itemType;
-        if (documentOrItemId.regionMatches(true, documentOrItemId.length() - 
suffix.length(), suffix, 0, suffix.length())) {
-            return documentOrItemId.substring(0, documentOrItemId.length() - 
suffix.length());
+        if (idsByType.isEmpty()) {
+            throw new IllegalStateException(resource + " is empty: shipped 
definitions would be migrated to the wrong tenant");
         }
-        return documentOrItemId;
+        return idsByType;
     }
 
     /**
-     * Decides whether a 3.0 item must land in the system tenant after 
migration.
-     * Shipped definitions and geonames go to system. User-created rules, 
segments,
-     * scorings and goals stay in the configured tenant even when their 3.0 
scope is
-     * {@code systemscope}. Unknown indices forced to the default tenant never 
use system.
+     * Builds an update by query request running a Painless script on the 
documents of an item type.
+     * The item type is matched on both the keyword sub-field and the folded 
text field, so camelCase
+     * values such as {@code conditionType} are not missed.
      */
-    public static boolean shouldAssignToSystemTenant(String itemType, String 
itemId, String scope,
-                                                     String indexName, boolean 
forceDefaultTenant,
-                                                     Collection<String> 
shippedDefinitionIds) {
-        if (forceDefaultTenant) {
-            return false;
-        }
-        String typeLower = itemType == null ? "" : 
itemType.toLowerCase(Locale.ROOT);
-        if ("geonameentry".equals(typeLower)
-                || (indexName != null && 
indexName.toLowerCase(Locale.ROOT).endsWith("-geonameentry"))) {
-            return true;
-        }
-        String baseId = resolveBaseItemId(itemId, itemType);
-        if (baseId != null && shippedDefinitionIds != null && 
!typeLower.isEmpty()) {
-            String key = typeLower + ":" + baseId.toLowerCase(Locale.ROOT);
-            if (shippedDefinitionIds.contains(key)) {
-                return true;
-            }
-        }
-        // Literal scope "system" only — not the 3.0 default "systemscope" 
used by user content
-        return "system".equals(scope);
+    public static String buildItemTypeUpdateRequest(String painlessScript, 
String itemType) {
+        JSONArray should = new JSONArray()
+                .put(new JSONObject().put("term", new 
JSONObject().put("itemType.keyword", itemType)))
+                .put(new JSONObject().put("term", new 
JSONObject().put("itemType", itemType.toLowerCase(Locale.ROOT))));
+        return new JSONObject()
+                .put("script", new JSONObject().put("source", 
painlessScript).put("lang", "painless"))
+                .put("query", new JSONObject().put("bool", new 
JSONObject().put("should", should).put("minimum_should_match", 1)))
+                .toString();
     }
 
     /**
-     * Same as {@link #updateByQuery(CloseableHttpClient, String, String, 
String)} but returns how
-     * many documents were updated, so callers can log the count.
+     * Runs an update by query synchronously, refreshes the index and returns 
how many documents were
+     * actually changed (documents the script marks as noop are not counted).
+     *
+     * @throws IOException if the update timed out or reported failures; 
version conflicts abort the request
      */
-    public static long updateByQueryAndCount(CloseableHttpClient httpClient, 
String esAddress, String indexName, String requestBody) throws Exception {
+    public static long updateByQueryAndCount(CloseableHttpClient httpClient, 
String esAddress, String indexName, String requestBody) throws IOException {
         JSONObject response = new JSONObject(HttpUtils.executePostRequest(
-                httpClient, esAddress + "/" + indexName + 
"/_update_by_query?refresh=true&conflicts=proceed", requestBody, null));
+                httpClient, esAddress + "/" + indexName + 
"/_update_by_query?refresh=true", requestBody, null));
+        JSONArray failures = response.optJSONArray("failures");
+        if (response.optBoolean("timed_out") || (failures != null && 
!failures.isEmpty())) {
+            throw new IOException("Update by query on " + indexName + " did 
not complete: " + response);
+        }
         return response.optLong("updated", 0L);
     }
 
diff --git 
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
 
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
index 65e927682..6f3c755b4 100644
--- 
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
+++ 
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
@@ -26,7 +26,7 @@ MigrationContext context = migrationContext
 String esAddress = context.getConfigString(CONFIG_ES_ADDRESS)
 String indexPrefix = context.getConfigString(INDEX_PREFIX)
 String tenantId = context.getConfigString(TENANT_ID)
-String systemTenantId = "system" // System tenant ID for system-level items
+String systemTenantId = "system"
 String rolloverPolicyName = indexPrefix + "-unomi-rollover-policy"
 String rolloverSessionAlias = indexPrefix + "-session"
 String rolloverEventAlias = indexPrefix + "-event"
@@ -109,8 +109,8 @@ context.performMigrationStep("4.0.0-get-all-indices", () -> 
{
             .collect { it.toLowerCase(java.util.Locale.ROOT) } as Set
     context.printMessage("Found " + systemItems.size() + " system items")
 
-    Set<String> shippedDefinitionIds = 
MigrationUtils.loadShippedDefinitionIds(bundleContext)
-    context.printMessage("Loaded " + shippedDefinitionIds.size() + " shipped 
definition ids")
+    Map<String, Set<String>> shippedDefinitionIds = 
MigrationUtils.loadShippedDefinitionIds(bundleContext)
+    context.printMessage("Loaded " + shippedDefinitionIds.values().sum { 
it.size() } + " shipped definition ids")
 
     // Create base parameters
     Map<String, Object> baseParams = new HashMap<>()
diff --git 
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-05-fixSystemItemIds.groovy
 
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-05-fixSystemItemIds.groovy
index e5c8fbdd3..6a5065fa1 100644
--- 
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-05-fixSystemItemIds.groovy
+++ 
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-05-fixSystemItemIds.groovy
@@ -1,7 +1,6 @@
 import org.apache.unomi.shell.migration.service.MigrationContext
 import org.apache.unomi.shell.migration.utils.MigrationUtils
 import org.apache.unomi.shell.migration.utils.HttpUtils
-import org.json.JSONObject
 import static org.apache.unomi.shell.migration.service.MigrationConfig.*
 
 /*
@@ -43,41 +42,33 @@ context.performMigrationStep("4.0.0-fix-system-item-ids", 
() -> {
         context.printMessage("Fixing itemIds in systemitems index that end 
with itemType suffix")
 
         long totalUpdated = 0L
+        List<String> failedItemTypes = []
 
-        // Process each system item type. Match on itemType.keyword so 
camelCase values
-        // such as conditionType / actionType / propertyType are not missed 
(UNOMI-997).
+        // Process each system item type. The query matches camelCase values 
such as
+        // conditionType / actionType / propertyType (UNOMI-997).
         systemItems.each { itemType ->
             context.printMessage("Fixing items of type: ${itemType}")
 
             String fixScript = 
MigrationUtils.getFileWithoutComments(bundleContext, 
"requestBody/4.0.0/fix_system_item_ids.painless")
 
-            JSONObject scriptObj = new JSONObject()
-            scriptObj.put("source", fixScript)
-            scriptObj.put("lang", "painless")
-
-            // Match camelCase item types on both the keyword sub-field and 
the folded text field
-            JSONObject keywordTerm = new JSONObject().put("term", new 
JSONObject().put("itemType.keyword", itemType))
-            JSONObject foldedTerm = new JSONObject().put("term", new 
JSONObject().put("itemType", itemType.toLowerCase(java.util.Locale.ROOT)))
-            JSONObject boolQuery = new JSONObject()
-            boolQuery.put("should", [keywordTerm, foldedTerm])
-            boolQuery.put("minimum_should_match", 1)
-            JSONObject queryObj = new JSONObject().put("bool", boolQuery)
-
-            JSONObject updateRequestObj = new JSONObject()
-            updateRequestObj.put("script", scriptObj)
-            updateRequestObj.put("query", queryObj)
+            String updateRequest = 
MigrationUtils.buildItemTypeUpdateRequest(fixScript, itemType)
 
             try {
-                long updated = 
MigrationUtils.updateByQueryAndCount(context.getHttpClient(), esAddress, 
systemItemsIndex, updateRequestObj.toString())
+                long updated = 
MigrationUtils.updateByQueryAndCount(context.getHttpClient(), esAddress, 
systemItemsIndex, updateRequest)
                 totalUpdated += updated
                 context.printMessage("Fixed ${updated} itemId(s) for item 
type: ${itemType}")
             } catch (Exception e) {
                 context.printMessage("Warning: Could not fix itemIds for item 
type ${itemType}: ${e.getMessage()}")
+                failedItemTypes.add(itemType)
             }
         }
 
         HttpUtils.executePostRequest(context.getHttpClient(), esAddress + 
"/${systemItemsIndex}/_refresh", null, null)
         context.printMessage("Fixed ${totalUpdated} itemId(s) in systemitems 
index")
+        // Fail the step so it is retried on the next run instead of being 
recorded as completed
+        if (!failedItemTypes.isEmpty()) {
+            throw new IllegalStateException("Could not fix itemIds for item 
types: " + failedItemTypes)
+        }
     } else {
         context.printMessage("Systemitems index does not exist, skipping 
itemId fix")
     }
diff --git 
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/fix_system_item_ids.painless
 
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/fix_system_item_ids.painless
index 9d309d1cc..fdab21556 100644
--- 
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/fix_system_item_ids.painless
+++ 
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/fix_system_item_ids.painless
@@ -38,34 +38,15 @@ if (ctx._source.itemId != null && ctx._source.itemType != 
null && ctx._id != nul
         documentIdWithoutTenant = 
documentIdWithoutTenant.substring(firstUnderscoreIndex + 1);
     }
     
-    // For system items, the expected format after 2.2.0 migration is itemId = 
documentId (with suffix)
-    // However, new items created after migrations may have itemId = original 
(without suffix)
-    // Both work because setMetadata() extracts from document ID, but we 
normalize to the migrated format
-    // for consistency and to fix items affected by the 4.0.0-00 bug
-    def itemTypeSuffix = '_' + ctx._source.itemType.toLowerCase();
-    if (documentIdWithoutTenant.endsWith(itemTypeSuffix)) {
-        // Document ID has the expected suffix format - itemId should match it
-        if (ctx._source.itemId != documentIdWithoutTenant) {
-            ctx._source.itemId = documentIdWithoutTenant;
-        }
+    // Every case above ends the same way: itemId must equal the document ID 
minus the tenant prefix.
+    // When the document ID lost its suffix (4.0.0-00 bug) it cannot be 
repaired by update_by_query
+    // (that needs a reindex), but aligning itemId on it still lets 
setMetadata() work.
+    // Documents that are already consistent are left untouched so they are 
not counted as updated.
+    if (ctx._source.itemId != documentIdWithoutTenant) {
+        ctx._source.itemId = documentIdWithoutTenant;
     } else {
-        // Document ID doesn't have suffix (pre-2.2.0 format or incorrectly 
processed by 4.0.0-00)
-        // Check if source itemId has the suffix - this indicates the document 
ID is wrong (from buggy 4.0.0-00)
-        if (ctx._source.itemId != null && 
ctx._source.itemId.endsWith(itemTypeSuffix)) {
-            // Source itemId has suffix but document ID doesn't - document ID 
was incorrectly processed
-            // We can't fix the document ID with update_by_query (would need 
reindexing),
-            // but we can at least ensure itemId matches the document ID so 
setMetadata() can work
-            // Note: This item will need to be reindexed to fix the document 
ID properly
-            if (ctx._source.itemId != documentIdWithoutTenant) {
-                ctx._source.itemId = documentIdWithoutTenant;
-            }
-        } else {
-            // Document ID doesn't have suffix and source itemId doesn't either
-            // This is either pre-2.2.0 format or a legitimate case - ensure 
they match
-            if (ctx._source.itemId != documentIdWithoutTenant) {
-                ctx._source.itemId = documentIdWithoutTenant;
-            }
-        }
+        ctx.op = 'noop';
     }
+} else {
+    ctx.op = 'noop';
 }
-
diff --git 
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
 
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
index 5e4591fdc..97dcd9b38 100644
--- 
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
+++ 
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
@@ -18,21 +18,27 @@
 // Decide the tenant for this document (UNOMI-997).
 // Shipped definitions and geonames go to system. User-created 
rules/segments/scorings/goals
 // stay in the configured tenant even when their 3.0 scope is "systemscope".
+// A shipped definition is recognised by item type and id only, so a 3.0 item 
sharing both
+// (such as a built-in rule edited in place) also goes to system.
 boolean useSystemTenant = false;
 if (params.forceDefaultTenant != true) {
-    def itemTypeLower = ctx._source.itemType != null ? 
ctx._source.itemType.toLowerCase() : '';
-    def rawId = ctx._source.itemId != null ? ctx._source.itemId : ctx._id;
-    def baseId = rawId;
-    if (baseId != null && itemTypeLower != '' && 
baseId.toLowerCase().endsWith('_' + itemTypeLower)) {
-        baseId = baseId.substring(0, baseId.length() - itemTypeLower.length() 
- 1);
-    }
-    if (itemTypeLower == 'geonameentry' || (ctx._index != null && 
ctx._index.toLowerCase().endsWith('-geonameentry'))) {
-        useSystemTenant = true;
-    } else if (baseId != null && params.shippedDefinitionIds != null
-            && params.shippedDefinitionIds.contains(itemTypeLower + ':' + 
baseId.toLowerCase())) {
+    def itemTypeLower = ctx._source.itemType != null ? 
ctx._source.itemType.toLowerCase(Locale.ROOT) : '';
+    // Shipped ids are grouped by item type, so other types (profiles, 
events...) skip the lookup
+    def shippedIds = params.shippedDefinitionIds != null ? 
params.shippedDefinitionIds.get(itemTypeLower) : null;
+    if (itemTypeLower == 'geonameentry' || (ctx._index != null && 
ctx._index.toLowerCase(Locale.ROOT).endsWith('-geonameentry'))) {
         useSystemTenant = true;
-    } else if (ctx._source.scope != null && ctx._source.scope == 'system') {
-        // Literal "system" only — not the 3.0 default "systemscope"
+    } else if (shippedIds != null) {
+        def rawId = ctx._source.itemId != null ? ctx._source.itemId : ctx._id;
+        if (rawId != null) {
+            def shippedId = rawId.toLowerCase(Locale.ROOT);
+            if (shippedId.endsWith('_' + itemTypeLower)) {
+                shippedId = shippedId.substring(0, shippedId.length() - 
itemTypeLower.length() - 1);
+            }
+            useSystemTenant = shippedIds.contains(shippedId);
+        }
+    }
+    // Literal "system" only — not the 3.0 default "systemscope"
+    if (!useSystemTenant && ctx._source.scope == 'system') {
         useSystemTenant = true;
     }
 }
@@ -43,10 +49,12 @@ if (ctx._id != null) {
     // Skip if already has tenant prefix
     if (!ctx._id.startsWith(params.tenantId + '_') && 
!ctx._id.startsWith(params.systemTenantId + '_')) {
         def baseId = ctx._id;
-        if (ctx._index != null && 
ctx._index.toLowerCase().endsWith('-systemitems')) {
-            // For system items, ensure itemType is lowercase and matches the 
format in ElasticSearchPersistenceServiceImpl
-            def itemType = ctx._source.itemType != null ? 
ctx._source.itemType.toLowerCase() : null;
-            if (itemType != null && params.systemItems != null && 
params.systemItems.contains(itemType)) {
+        if (ctx._index != null && 
ctx._index.toLowerCase(Locale.ROOT).endsWith('-systemitems')) {
+            // For system items the document ID is tenantId_itemId_itemtype 
(see getDocumentIDForItemType).
+            // 3.0 document IDs already carry the lower-case _itemtype suffix, 
so only add it when missing.
+            def itemType = ctx._source.itemType != null ? 
ctx._source.itemType.toLowerCase(Locale.ROOT) : null;
+            if (itemType != null && params.systemItems != null && 
params.systemItems.contains(itemType)
+                    && !baseId.toLowerCase(Locale.ROOT).endsWith('_' + 
itemType)) {
                 ctx._id = assignedTenantId + '_' + baseId + '_' + itemType;
             } else {
                 ctx._id = assignedTenantId + '_' + baseId;
@@ -61,7 +69,7 @@ if (ctx._id != null) {
 // systemitems must get audit here: UNOMI-997 puts shipped defs under the 
system
 // tenant before Unomi starts, so bundle deploy treats them as updates and 
would
 // otherwise leave createdBy unset on docs that never had it.
-def indexLower = ctx._index != null ? ctx._index.toLowerCase() : '';
+def indexLower = ctx._index != null ? ctx._index.toLowerCase(Locale.ROOT) : '';
 if (!indexLower.endsWith('-geonameentry') && 
!indexLower.endsWith('-geonames')) {
     // Handle creation date based on item type
     if (ctx._source.creationDate == null) {
diff --git 
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/shipped_system_definitions.json
 
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/shipped_system_definitions.json
index 22f1bfda8..3d8cd07ad 100644
--- 
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/shipped_system_definitions.json
+++ 
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/shipped_system_definitions.json
@@ -60,6 +60,10 @@
   "conditiontype:updatepropertieseventcondition",
   "conditiontype:userlistpropertycondition",
   "conditiontype:videovieweventcondition",
+  "persona:europeanvisitor",
+  "persona:usvisitor",
+  "personasession:51c8e9a8-3db6-41f8-9569-057c339e3017",
+  "personasession:aa3b04bd-8f4d-4a07-8e96-d33ffa04d3d9",
   "propertytype:address",
   "propertytype:age",
   "propertytype:birthdate",
diff --git 
a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
 
b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
index cb0322d1e..f2c1ea1a1 100644
--- 
a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
+++ 
b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
@@ -16,23 +16,34 @@
  */
 package org.apache.unomi.shell.migration.utils;
 
+import org.json.JSONArray;
+import org.json.JSONObject;
 import org.junit.Before;
 import org.junit.Test;
 import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleContext;
 
 import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
 import java.io.InputStream;
 import java.net.URL;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitResult;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
 
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.Mockito.*;
@@ -305,58 +316,97 @@ public class MigrationUtilsTest {
         assertEquals("generic", 
MigrationUtils.resolveItemType("other-profile", "context", types));
     }
 
-    @Test
-    public void resolveBaseItemIdStripsItemTypeSuffixIgnoringCase() {
-        assertEquals("booleanCondition",
-                
MigrationUtils.resolveBaseItemId("booleanCondition_conditionType", 
"conditionType"));
-        assertEquals("booleanCondition",
-                
MigrationUtils.resolveBaseItemId("booleanCondition_conditiontype", 
"conditionType"));
-        assertEquals("booleanCondition",
-                MigrationUtils.resolveBaseItemId("booleanCondition", 
"conditionType"));
-    }
-
-    @Test
-    public void shouldAssignToSystemTenantForShippedConditionWithoutScope() {
-        Collection<String> shipped = 
Collections.singleton("conditiontype:booleancondition");
-        assertTrue(MigrationUtils.shouldAssignToSystemTenant(
-                "conditionType", "booleanCondition", null, 
"context-systemitems", false, shipped));
-    }
+    private static final String SHIPPED_DEFINITIONS = 
"requestBody/4.0.0/shipped_system_definitions.json";
 
     @Test
-    public void shouldAssignUserContentWithSystemScopeToDefaultTenant() {
-        Collection<String> shipped = 
Collections.singleton("conditiontype:booleancondition");
-        assertFalse(MigrationUtils.shouldAssignToSystemTenant(
-                "rule", "my-user-rule", "systemscope", "context-systemitems", 
false, shipped));
-        assertFalse(MigrationUtils.shouldAssignToSystemTenant(
-                "segment", "my-segment", "systemscope", "context-systemitems", 
false, shipped));
-        assertFalse(MigrationUtils.shouldAssignToSystemTenant(
-                "scoring", "my-scoring", "systemscope", "context-systemitems", 
false, shipped));
-        assertFalse(MigrationUtils.shouldAssignToSystemTenant(
-                "goal", "my-goal", "systemscope", "context-systemitems", 
false, shipped));
+    public void loadShippedDefinitionIdsGroupsLowerCaseIdsByType() throws 
Exception {
+        stubShippedDefinitions("[\"conditionType:booleanCondition\", 
\"rule:_abc_myRule.json\", \"rule:other\"]");
+        Map<String, Set<String>> shipped = 
MigrationUtils.loadShippedDefinitionIds(bundleContext);
+        assertEquals(Set.of("conditiontype", "rule"), shipped.keySet());
+        assertEquals(Set.of("booleancondition"), shipped.get("conditiontype"));
+        assertEquals(Set.of("_abc_myrule.json", "other"), shipped.get("rule"));
     }
 
     @Test
-    public void shouldAssignGeonamesToSystemTenant() {
-        assertTrue(MigrationUtils.shouldAssignToSystemTenant(
-                "geonameEntry", "paris", null, "context-geonameentry", false, 
Collections.emptySet()));
-        assertTrue(MigrationUtils.shouldAssignToSystemTenant(
-                null, "paris", null, "context-geonameentry", false, 
Collections.emptySet()));
-    }
-
-    @Test
-    public void shouldAssignProfilesEventsAndForcedDefaultToConfiguredTenant() 
{
-        Collection<String> shipped = 
Collections.singleton("conditiontype:booleancondition");
-        assertFalse(MigrationUtils.shouldAssignToSystemTenant(
-                "profile", "p1", null, "context-profile", false, shipped));
-        assertFalse(MigrationUtils.shouldAssignToSystemTenant(
-                "event", "e1", null, "context-event-000001", false, shipped));
-        assertFalse(MigrationUtils.shouldAssignToSystemTenant(
-                "clusterNode", "node1", "system", "context-clusternode", true, 
shipped));
+    public void loadShippedDefinitionIdsRejectsEmptyOrMalformedLists() throws 
Exception {
+        for (String content : Arrays.asList("[]", "[\"booleanCondition\"]", 
"[\":booleanCondition\"]", "[\"rule:\"]", "[42]")) {
+            stubShippedDefinitions(content);
+            assertThrows(content, IllegalStateException.class, () -> 
MigrationUtils.loadShippedDefinitionIds(bundleContext));
+        }
     }
 
-    @Test
-    public void shouldAssignLiteralSystemScopeToSystemTenant() {
-        assertTrue(MigrationUtils.shouldAssignToSystemTenant(
-                "sfdcConfiguration", "cfg1", "system", 
"context-sfdcconfiguration", false, Collections.emptySet()));
+    /**
+     * The shipped list is a snapshot: it must be updated whenever a 
definition is added to or
+     * removed from the bundles, or migrated copies land in the wrong tenant.
+     */
+    @Test
+    public void shippedDefinitionsListMatchesTheDefinitionsInTheSourceTree() 
throws Exception {
+        Path repositoryRoot = 
Path.of("").toAbsolutePath().getParent().getParent();
+        assertTrue("Unexpected source layout: " + repositoryRoot, 
Files.isDirectory(repositoryRoot.resolve("plugins")));
+
+        Map<String, String> typeByDirectory = Map.of("conditions", 
"conditiontype", "actions", "actiontype",
+                "properties", "propertytype", "rules", "rule", "personas", 
"persona");
+        Map<String, Set<String>> expected = new TreeMap<>();
+        Files.walkFileTree(repositoryRoot, new SimpleFileVisitor<Path>() {
+            @Override
+            public FileVisitResult preVisitDirectory(Path dir, 
BasicFileAttributes attrs) {
+                String name = dir.getFileName().toString();
+                boolean skip = name.startsWith(".") || name.equals("target") 
|| name.equals("node_modules") || name.equals("test");
+                return skip ? FileVisitResult.SKIP_SUBTREE : 
FileVisitResult.CONTINUE;
+            }
+
+            @Override
+            public FileVisitResult visitFile(Path file, BasicFileAttributes 
attrs) throws IOException {
+                String path = 
repositoryRoot.relativize(file).toString().replace(File.separatorChar, '/');
+                int cxs = path.indexOf("/src/main/resources/META-INF/cxs/");
+                if (cxs < 0 || !path.endsWith(".json")) {
+                    return FileVisitResult.CONTINUE;
+                }
+                String directory = path.substring(cxs + 
"/src/main/resources/META-INF/cxs/".length()).split("/")[0];
+                String type = typeByDirectory.get(directory);
+                if (type == null) {
+                    return FileVisitResult.CONTINUE;
+                }
+                JSONObject definition = new JSONObject(Files.readString(file));
+                if ("persona".equals(type)) {
+                    addExpected(expected, type, 
definition.getJSONObject("persona").getString("itemId"));
+                    JSONArray sessions = definition.getJSONArray("sessions");
+                    for (int i = 0; i < sessions.length(); i++) {
+                        addExpected(expected, "personasession", 
sessions.getJSONObject(i).getString("itemId"));
+                    }
+                } else {
+                    addExpected(expected, type, 
definition.getJSONObject("metadata").getString("id"));
+                }
+                return FileVisitResult.CONTINUE;
+            }
+        });
+
+        
when(bundle.getResource(SHIPPED_DEFINITIONS)).thenReturn(getClass().getResource("/"
 + SHIPPED_DEFINITIONS));
+        assertEquals("Update " + SHIPPED_DEFINITIONS + " to match the 
definitions shipped under META-INF/cxs",
+                expected, 
MigrationUtils.loadShippedDefinitionIds(bundleContext));
+    }
+
+    private static void addExpected(Map<String, Set<String>> expected, String 
type, String id) {
+        expected.computeIfAbsent(type, key -> new 
TreeSet<>()).add(id.toLowerCase(Locale.ROOT));
+    }
+
+    private void stubShippedDefinitions(String content) throws IOException {
+        Path file = Files.createTempFile("shipped-definitions", ".json");
+        file.toFile().deleteOnExit();
+        Files.writeString(file, content);
+        
when(bundle.getResource(SHIPPED_DEFINITIONS)).thenReturn(file.toUri().toURL());
+    }
+
+    @Test
+    public void buildItemTypeUpdateRequestMatchesKeywordAndFoldedItemType() {
+        JSONObject request = new 
JSONObject(MigrationUtils.buildItemTypeUpdateRequest("ctx.op = 'noop'", 
"conditionType"));
+        assertEquals("ctx.op = 'noop'", 
request.getJSONObject("script").getString("source"));
+        assertEquals("painless", 
request.getJSONObject("script").getString("lang"));
+        JSONObject bool = request.getJSONObject("query").getJSONObject("bool");
+        assertEquals(1, bool.getInt("minimum_should_match"));
+        JSONArray should = bool.getJSONArray("should");
+        assertEquals(2, should.length());
+        assertEquals("conditionType", 
should.getJSONObject(0).getJSONObject("term").getString("itemType.keyword"));
+        assertEquals("conditiontype", 
should.getJSONObject(1).getJSONObject("term").getString("itemType"));
     }
 }

Reply via email to