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

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new 31fe93e4f UNOMI-920: Improve debugging with YAML-based toString() for 
core API types (#768)
31fe93e4f is described below

commit 31fe93e4f0e79abe7d223d8d76dcd2ea437d3ab7
Author: Serge Huber <[email protected]>
AuthorDate: Sat May 30 12:01:50 2026 +0200

    UNOMI-920: Improve debugging with YAML-based toString() for core API types 
(#768)
---
 .github/workflows/codeql-analysis-java.yml         |   1 +
 .github/workflows/codeql-analysis-javascript.yml   |   1 +
 .github/workflows/unomi-ci-build-tests.yml         |   9 +-
 api/pom.xml                                        |  24 +
 api/src/main/java/org/apache/unomi/api/Item.java   |  65 +-
 .../main/java/org/apache/unomi/api/Metadata.java   |  45 +-
 .../java/org/apache/unomi/api/MetadataItem.java    |  56 +-
 .../main/java/org/apache/unomi/api/Parameter.java  |  67 +-
 .../java/org/apache/unomi/api/actions/Action.java  |  47 +-
 .../org/apache/unomi/api/actions/ActionType.java   |  41 +-
 .../org/apache/unomi/api/conditions/Condition.java | 141 +++-
 .../apache/unomi/api/conditions/ConditionType.java |  44 +-
 .../main/java/org/apache/unomi/api/goals/Goal.java |  44 +-
 .../main/java/org/apache/unomi/api/rules/Rule.java |  47 +-
 .../org/apache/unomi/api/segments/Scoring.java     |  39 +-
 .../apache/unomi/api/segments/ScoringElement.java  |  46 +-
 .../org/apache/unomi/api/segments/Segment.java     |  40 +-
 .../java/org/apache/unomi/api/utils/YamlUtils.java | 330 ++++++++++
 .../java/org/apache/unomi/api/ParameterTest.java   |  81 +++
 .../apache/unomi/api/conditions/ConditionTest.java | 170 +++++
 .../org/apache/unomi/api/utils/YamlUtilsTest.java  | 729 +++++++++++++++++++++
 bom/pom.xml                                        |  20 +
 build.sh                                           |   9 +-
 itests/pom.xml                                     |  22 +-
 .../unomi/itests/PropertiesUpdateActionIT.java     |   3 +-
 pom.xml                                            |   5 +
 .../apache/unomi/rest/models/RESTParameter.java    |   6 +-
 .../services/impl/rules/RulesServiceImpl.java      |  10 +-
 28 files changed, 2085 insertions(+), 57 deletions(-)

diff --git a/.github/workflows/codeql-analysis-java.yml 
b/.github/workflows/codeql-analysis-java.yml
index 0b8b7c427..c823fc472 100644
--- a/.github/workflows/codeql-analysis-java.yml
+++ b/.github/workflows/codeql-analysis-java.yml
@@ -17,6 +17,7 @@ on:
   pull_request:
     # The branches below must be a subset of the branches above
     branches: [ master ]
+  workflow_dispatch:
   schedule:
     - cron: '38 1 * * 0'
 
diff --git a/.github/workflows/codeql-analysis-javascript.yml 
b/.github/workflows/codeql-analysis-javascript.yml
index 542b973b6..39efa7cbe 100644
--- a/.github/workflows/codeql-analysis-javascript.yml
+++ b/.github/workflows/codeql-analysis-javascript.yml
@@ -17,6 +17,7 @@ on:
   pull_request:
     # The branches below must be a subset of the branches above
     branches: [ master ]
+  workflow_dispatch:
   schedule:
     - cron: '38 1 * * 0'
 
diff --git a/.github/workflows/unomi-ci-build-tests.yml 
b/.github/workflows/unomi-ci-build-tests.yml
index 2533a8b23..af90f2747 100644
--- a/.github/workflows/unomi-ci-build-tests.yml
+++ b/.github/workflows/unomi-ci-build-tests.yml
@@ -85,8 +85,15 @@ jobs:
             itests/target/exam/**/data/log
             itests/target/elasticsearch0/data
             itests/target/elasticsearch0/logs
+      # Always publish so a later "re-run failed jobs" pass updates the check 
to green.
+      # Previously `if: failure()` left a stale red "JUnit Test Report" when 
ITs passed on re-run.
       - name: Publish Test Report
         uses: mikepenz/action-junit-report@v3
-        if: failure()
+        if: always()
+        continue-on-error: true
         with:
           report_paths: 'itests/target/failsafe-reports/TEST-*.xml'
+          check_name: 'JUnit Test Report (${{ matrix.search-engine }})'
+          update_check: true
+          fail_on_failure: false
+          require_tests: false
diff --git a/api/pom.xml b/api/pom.xml
index af8c96220..89d7d56ad 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -66,6 +66,7 @@
         <dependency>
             <groupId>org.slf4j</groupId>
             <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
             <scope>provided</scope>
         </dependency>
         <dependency>
@@ -73,6 +74,29 @@
             <artifactId>jackson-databind</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.yaml</groupId>
+            <artifactId>snakeyaml</artifactId>
+        </dependency>
+
+        <!-- Test Dependencies -->
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <!-- SLF4J Implementation for Testing -->
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <reporting>
diff --git a/api/src/main/java/org/apache/unomi/api/Item.java 
b/api/src/main/java/org/apache/unomi/api/Item.java
index de283ebe9..482802588 100644
--- a/api/src/main/java/org/apache/unomi/api/Item.java
+++ b/api/src/main/java/org/apache/unomi/api/Item.java
@@ -17,14 +17,18 @@
 
 package org.apache.unomi.api;
 
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.Serializable;
-import java.util.HashMap;
-import java.util.Map;
+import java.util.*;
 import java.util.concurrent.ConcurrentHashMap;
 
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
+
 /**
  * A context server tracked entity. All tracked entities need to extend this 
class so as to provide the minimal information the context server needs to be 
able to track such
  * entities and operate on them. Items are persisted according to their type 
(structure) and identifier (identity). Of note, all Item subclasses 
<strong>must</strong> define a
@@ -36,10 +40,13 @@ import java.util.concurrent.ConcurrentHashMap;
  * though scopes could span across sites depending on the desired analysis 
granularity). Scopes allow clients accessing the context server to filter data. 
The context server
  * defines a built-in scope ({@link Metadata#SYSTEM_SCOPE}) that clients can 
use to share data across scopes.
  */
-public abstract class Item implements Serializable {
+public abstract class Item implements Serializable, YamlConvertible {
     private static final Logger LOGGER = 
LoggerFactory.getLogger(Item.class.getName());
 
-    private static final long serialVersionUID = 7446061538573517071L;
+    /**
+     * Java serialization version; Unomi does not rely on Java serialization 
of this type as a cross-version persistence contract.
+     */
+    private static final long serialVersionUID = 1217180125083162915L;
 
     private static final Map<Class,String> itemTypeCache = new 
ConcurrentHashMap<>();
 
@@ -150,4 +157,54 @@ public abstract class Item implements Serializable {
     public void setSystemMetadata(String key, Object value) {
         systemMetadata.put(key, value);
     }
+
+    /**
+     * Converts this item to a Map structure for YAML output.
+     * Implements YamlConvertible interface with circular reference detection.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this item
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlMapBuilder.create()
+                .put("itemId", itemId)
+                .put("itemType", itemType)
+                .put("systemMetadata", "<max depth exceeded>")
+                .build();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        // Check if already visited - if so, we're being called from a child 
class via super.toYaml()
+        // OR it's a real circular reference. We can't distinguish, but since 
child classes
+        // (like Rule, ConditionType, etc.) all check for circular refs before 
calling super,
+        // if we're already visited here, it's safe to assume it's a super 
call, not a circular ref.
+        // If Item is directly serialized and encounters itself, the check 
would happen at the
+        // top level before nested processing, so this should be safe.
+        boolean alreadyVisited = visitedSet.contains(this);
+        if (!alreadyVisited) {
+            // First time seeing this object - add it to track for circular 
references
+            visitedSet.add(this);
+        }
+        try {
+            return YamlMapBuilder.create()
+                .put("itemId", itemId)  // Always include, even if null, to 
reflect actual state
+                .put("itemType", itemType)  // Always include, even if null, 
to reflect actual state
+                .putIfNotNull("scope", scope)
+                .putIfNotNull("version", version)
+                .putIfNotNull("systemMetadata", systemMetadata != null && 
!systemMetadata.isEmpty() ? toYamlValue(systemMetadata, visitedSet, maxDepth - 
1) : null)
+                .build();
+        } finally {
+            // Only remove if we added it (i.e., if it wasn't already visited)
+            if (!alreadyVisited) {
+                visitedSet.remove(this);
+            }
+        }
+    }
+
+    @Override
+    public String toString() {
+        Map<String, Object> map = toYaml();
+        return YamlUtils.format(map);
+    }
 }
diff --git a/api/src/main/java/org/apache/unomi/api/Metadata.java 
b/api/src/main/java/org/apache/unomi/api/Metadata.java
index 9a112e766..4f650589f 100644
--- a/api/src/main/java/org/apache/unomi/api/Metadata.java
+++ b/api/src/main/java/org/apache/unomi/api/Metadata.java
@@ -17,16 +17,23 @@
 
 package org.apache.unomi.api;
 
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
 import java.io.Serializable;
 import java.util.LinkedHashSet;
+import java.util.Map;
 import java.util.Set;
 
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+
 /**
  * A class providing information about context server entities.
  *
  * @see MetadataItem
  */
-public class Metadata implements Comparable<Metadata>, Serializable {
+public class Metadata implements Comparable<Metadata>, Serializable, 
YamlConvertible {
 
     private static final long serialVersionUID = 7446061538573517071L;
 
@@ -279,5 +286,41 @@ public class Metadata implements Comparable<Metadata>, 
Serializable {
         return result;
     }
 
+    /**
+     * Converts this metadata to a Map structure for YAML output.
+     * Implements YamlConvertible interface with circular reference detection.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this metadata
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (visited != null && visited.contains(this)) {
+            return circularRef();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        visitedSet.add(this);
+        try {
+            return YamlMapBuilder.create()
+                .putIfNotNull("id", id)
+                .putIfNotNull("name", name)
+                .putIfNotNull("description", description)
+                .putIfNotNull("scope", scope)
+                .putIfNotEmpty("tags", tags)
+                .putIfNotEmpty("systemTags", systemTags)
+                .putIf("enabled", true, enabled)
+                .putIf("missingPlugins", true, missingPlugins)
+                .putIf("hidden", true, hidden)
+                .putIf("readOnly", true, readOnly)
+                .build();
+        } finally {
+            visitedSet.remove(this);
+        }
+    }
 
+    @Override
+    public String toString() {
+        Map<String, Object> map = toYaml();
+        return YamlUtils.format(map);
+    }
 }
diff --git a/api/src/main/java/org/apache/unomi/api/MetadataItem.java 
b/api/src/main/java/org/apache/unomi/api/MetadataItem.java
index 78a419863..68cf3b324 100644
--- a/api/src/main/java/org/apache/unomi/api/MetadataItem.java
+++ b/api/src/main/java/org/apache/unomi/api/MetadataItem.java
@@ -17,8 +17,15 @@
 
 package org.apache.unomi.api;
 
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
 import javax.xml.bind.annotation.XmlElement;
 import javax.xml.bind.annotation.XmlTransient;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
 
 /**
  * A superclass for all {@link Item}s that bear {@link Metadata}.
@@ -31,7 +38,7 @@ public abstract class MetadataItem extends Item {
     }
 
     public MetadataItem(Metadata metadata) {
-        super(metadata.getId());
+        super(metadata != null ? metadata.getId() : null);
         this.metadata = metadata;
     }
 
@@ -54,7 +61,52 @@ public abstract class MetadataItem extends Item {
 
     @XmlTransient
     public String getScope() {
-        return metadata.getScope();
+        if (metadata != null) {
+            return metadata.getScope();
+        }
+        return scope;
+    }
+
+    /**
+     * Converts this metadata item to a Map structure for YAML output.
+     * Merges fields from Item parent class and adds metadata field.
+     * Subclasses should override this method, call super.toYaml(visited), and 
add their specific fields.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this metadata item
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlMapBuilder.create()
+                .put("metadata", "<max depth exceeded>")
+                .build();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        // Check if already visited - if so, we're being called from a child 
class via super.toYaml()
+        // In that case, skip the circular reference check and just proceed
+        boolean alreadyVisited = visitedSet.contains(this);
+        if (!alreadyVisited) {
+            // Only check for circular references if this is the first time 
we're seeing this object
+            visitedSet.add(this);
+        }
+        try {
+            return YamlMapBuilder.create()
+                .mergeObject(super.toYaml(visitedSet, maxDepth))
+                .putIfNotNull("metadata", metadata != null ? 
toYamlValue(metadata, visitedSet, maxDepth - 1) : null)
+                .build();
+        } finally {
+            // Only remove if we added it (i.e., if it wasn't already visited)
+            if (!alreadyVisited) {
+                visitedSet.remove(this);
+            }
+        }
     }
 
+
+    @Override
+    public String toString() {
+        Map<String, Object> map = toYaml();
+        return YamlUtils.format(map);
+    }
 }
diff --git a/api/src/main/java/org/apache/unomi/api/Parameter.java 
b/api/src/main/java/org/apache/unomi/api/Parameter.java
index 4833c5a5f..7fc7c7453 100644
--- a/api/src/main/java/org/apache/unomi/api/Parameter.java
+++ b/api/src/main/java/org/apache/unomi/api/Parameter.java
@@ -17,20 +17,28 @@
 
 package org.apache.unomi.api;
 
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+
 import java.io.Serializable;
+import java.util.Map;
+import java.util.Set;
 
 /**
  * A representation of a condition parameter, to be used in the segment 
building UI to either select parameters from a
  * choicelist or to enter a specific value.
  */
-public class Parameter implements Serializable {
+public class Parameter implements Serializable, YamlConvertible {
 
-    private static final long serialVersionUID = 7446061538573517071L;
+    /**
+     * Java serialization version; Unomi does not rely on Java serialization 
of this type as a cross-version persistence contract.
+     */
+    private static final long serialVersionUID = 6019392686888941547L;
 
-    String id;
-    String type;
-    boolean multivalued = false;
-    String defaultValue = null;
+    private String id;
+    private String type;
+    private boolean multivalued;
+    private Object defaultValue;
 
     public Parameter() {
     }
@@ -45,14 +53,26 @@ public class Parameter implements Serializable {
         return id;
     }
 
+    public void setId(String id) {
+        this.id = id;
+    }
+
     public String getType() {
         return type;
     }
 
+    public void setType(String type) {
+        this.type = type;
+    }
+
     public boolean isMultivalued() {
         return multivalued;
     }
 
+    public void setMultivalued(boolean multivalued) {
+        this.multivalued = multivalued;
+    }
+
     /**
      * @param choiceListInitializerFilter a reference to a choicelist
      * @deprecated As of version 1.1.0-incubating
@@ -62,11 +82,42 @@ public class Parameter implements Serializable {
         // Avoid errors when deploying old definitions
     }
 
-    public String getDefaultValue() {
+    public Object getDefaultValue() {
         return defaultValue;
     }
 
-    public void setDefaultValue(String defaultValue) {
+    public void setDefaultValue(Object defaultValue) {
         this.defaultValue = defaultValue;
     }
+
+    /**
+     * Converts this parameter to a Map structure for YAML output.
+     * Implements YamlConvertible interface.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this parameter
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlUtils.YamlMapBuilder.create()
+                .putIfNotNull("id", id)
+                .putIfNotNull("type", type)
+                .putIf("multivalued", true, multivalued)
+                .putIfNotNull("defaultValue", "<max depth exceeded>")
+                .build();
+        }
+        return YamlUtils.YamlMapBuilder.create()
+            .putIfNotNull("id", id)
+            .putIfNotNull("type", type)
+            .putIf("multivalued", true, multivalued)
+            .putIfNotNull("defaultValue", defaultValue)
+            .build();
+    }
+
+    @Override
+    public String toString() {
+        return YamlUtils.format(toYaml());
+    }
+
 }
diff --git a/api/src/main/java/org/apache/unomi/api/actions/Action.java 
b/api/src/main/java/org/apache/unomi/api/actions/Action.java
index b3505bbe8..aeaba1bc6 100644
--- a/api/src/main/java/org/apache/unomi/api/actions/Action.java
+++ b/api/src/main/java/org/apache/unomi/api/actions/Action.java
@@ -18,18 +18,25 @@
 package org.apache.unomi.api.actions;
 
 import org.apache.unomi.api.rules.Rule;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
 
 import javax.xml.bind.annotation.XmlElement;
 import javax.xml.bind.annotation.XmlTransient;
 import java.io.Serializable;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
 
 /**
  * An action that can be executed as a consequence of a {@link Rule} being 
triggered. An action is characterized by its associated {@link
  * ActionType} and parameter values.
  */
-public class Action implements Serializable {
+public class Action implements Serializable, YamlConvertible {
     private ActionType actionType;
     private String actionTypeId;
     private Map<String, Object> parameterValues = new HashMap<>();
@@ -117,4 +124,42 @@ public class Action implements Serializable {
         parameterValues.put(name, value);
     }
 
+    /**
+     * Converts this action to a Map structure for YAML output.
+     * Implements YamlConvertible interface with circular reference detection.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this action
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlMapBuilder.create()
+                .put("type", actionTypeId != null ? actionTypeId : "Action")
+                .put("parameterValues", "<max depth exceeded>")
+                .build();
+        }
+        if (visited != null && visited.contains(this)) {
+            return circularRef();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        visitedSet.add(this);
+        try {
+            YamlMapBuilder builder = YamlMapBuilder.create()
+                .put("type", actionTypeId != null ? actionTypeId : "Action");
+            if (parameterValues != null && !parameterValues.isEmpty()) {
+                builder.put("parameterValues", toYamlValue(parameterValues, 
visitedSet, maxDepth - 1));
+            }
+            return builder.build();
+        } finally {
+            visitedSet.remove(this);
+        }
+    }
+
+    @Override
+    public String toString() {
+        Map<String, Object> map = toYaml();
+        return YamlUtils.format(map);
+    }
+
 }
diff --git a/api/src/main/java/org/apache/unomi/api/actions/ActionType.java 
b/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
index 5ff336d9f..5da949349 100644
--- a/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
+++ b/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
@@ -20,14 +20,19 @@ package org.apache.unomi.api.actions;
 import org.apache.unomi.api.Metadata;
 import org.apache.unomi.api.MetadataItem;
 import org.apache.unomi.api.Parameter;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
 
-import java.util.ArrayList;
-import java.util.List;
+import java.util.*;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
 
 /**
  * A type definition for {@link Action}s.
  */
-public class ActionType extends MetadataItem {
+public class ActionType extends MetadataItem implements YamlConvertible {
     public static final String ITEM_TYPE = "actionType";
 
     private static final long serialVersionUID = -3522958600710010935L;
@@ -101,4 +106,34 @@ public class ActionType extends MetadataItem {
     public int hashCode() {
         return itemId.hashCode();
     }
+
+    /**
+     * Converts this action type to a Map structure for YAML output.
+     * Implements YamlConvertible interface with circular reference detection.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this action type
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlMapBuilder.create()
+                .put("parameters", "<max depth exceeded>")
+                .build();
+        }
+        if (visited != null && visited.contains(this)) {
+            return circularRef();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        visitedSet.add(this);
+        try {
+            return YamlMapBuilder.create()
+                .mergeObject(super.toYaml(visitedSet, maxDepth))
+                .putIfNotNull("actionExecutor", actionExecutor)
+                .putIfNotEmpty("parameters", parameters != null ? 
(Collection<?>) toYamlValue(parameters, visitedSet, maxDepth - 1) : null)
+                .build();
+        } finally {
+            visitedSet.remove(this);
+        }
+    }
 }
diff --git a/api/src/main/java/org/apache/unomi/api/conditions/Condition.java 
b/api/src/main/java/org/apache/unomi/api/conditions/Condition.java
index 526ff463f..248d1c43a 100644
--- a/api/src/main/java/org/apache/unomi/api/conditions/Condition.java
+++ b/api/src/main/java/org/apache/unomi/api/conditions/Condition.java
@@ -17,16 +17,30 @@
 
 package org.apache.unomi.api.conditions;
 
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
 import javax.xml.bind.annotation.XmlElement;
 import javax.xml.bind.annotation.XmlTransient;
 import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
 
 /**
  * A set of elements that can be evaluated.
  */
-public class Condition implements Serializable {
+public class Condition implements Serializable, YamlConvertible {
     private static final long serialVersionUID = 7584522402785053206L;
 
     ConditionType conditionType;
@@ -65,7 +79,9 @@ public class Condition implements Serializable {
      */
     public void setConditionType(ConditionType conditionType) {
         this.conditionType = conditionType;
-        this.conditionTypeId = conditionType.getItemId();
+        if (conditionType != null) {
+            this.conditionTypeId = conditionType.getItemId();
+        }
     }
 
     /**
@@ -103,7 +119,7 @@ public class Condition implements Serializable {
      * @param parameterValues a Map containing the parameter name - value 
pairs for this profile
      */
     public void setParameterValues(Map<String, Object> parameterValues) {
-        this.parameterValues = parameterValues;
+        this.parameterValues = parameterValues != null ? parameterValues : new 
HashMap<>();
     }
 
     /**
@@ -113,7 +129,7 @@ public class Condition implements Serializable {
      * @return {@code true} if this condition contains a parameter with the 
specified name, {@code false} otherwise
      */
     public boolean containsParameter(String name) {
-        return parameterValues.containsKey(name);
+        return parameterValues != null && parameterValues.containsKey(name);
     }
 
     /**
@@ -123,7 +139,7 @@ public class Condition implements Serializable {
      * @return the value of the specified parameter or {@code null} if no such 
parameter exists
      */
     public Object getParameter(String name) {
-        return parameterValues.get(name);
+        return parameterValues != null ? parameterValues.get(name) : null;
     }
 
     /**
@@ -134,6 +150,9 @@ public class Condition implements Serializable {
      * @param value the value of the parameter
      */
     public void setParameter(String name, Object value) {
+        if (parameterValues == null) {
+            parameterValues = new HashMap<>();
+        }
         parameterValues.put(name, value);
     }
 
@@ -141,28 +160,112 @@ public class Condition implements Serializable {
     public boolean equals(Object o) {
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
-
         Condition condition = (Condition) o;
-
-        if (!conditionTypeId.equals(condition.conditionTypeId)) return false;
-        return parameterValues.equals(condition.parameterValues);
-
+        return Objects.equals(conditionTypeId, condition.conditionTypeId)
+                && Objects.equals(parameterValues, condition.parameterValues);
     }
 
     @Override
     public int hashCode() {
-        int result = conditionTypeId.hashCode();
-        result = 31 * result + parameterValues.hashCode();
-        return result;
+        return Objects.hash(conditionTypeId, parameterValues);
+    }
+
+    /**
+     * Converts this condition to a Map structure for YAML output with depth 
limiting.
+     * Implements YamlConvertible interface with circular reference detection 
and depth limiting
+     * to prevent StackOverflowError from extremely deep nested structures.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @param maxDepth maximum recursion depth (prevents StackOverflowError 
from deep nesting)
+     * @return a Map representation of this condition
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlMapBuilder.create()
+                .put("type", conditionTypeId != null ? conditionTypeId : 
"Condition")
+                .put("parameterValues", "<max depth exceeded>")
+                .build();
+        }
+        if (visited != null && visited.contains(this)) {
+            return circularRef();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        visitedSet.add(this);
+        try {
+            YamlMapBuilder builder = YamlMapBuilder.create()
+                .put("type", conditionTypeId != null ? conditionTypeId : 
"Condition");
+            if (parameterValues != null && !parameterValues.isEmpty()) {
+                builder.put("parameterValues", toYamlValue(parameterValues, 
visitedSet, maxDepth - 1));
+            }
+            return builder.build();
+        } finally {
+            visitedSet.remove(this);
+        }
+    }
+
+    /**
+     * Creates a deep copy of this condition, including all nested conditions 
in parameter values.
+     * Recursively copies all nested conditions to avoid sharing references.
+     *
+     * @return a deep copy of this condition
+     * @throws IllegalStateException if the condition graph contains a cycle 
through nested {@link Condition} values
+     */
+    public Condition deepCopy() {
+        return deepCopy(new IdentityHashMap<>());
+    }
+
+    private Condition deepCopy(IdentityHashMap<Condition, Boolean> copying) {
+        if (copying.put(this, Boolean.TRUE) != null) {
+            throw new IllegalStateException("Cyclic Condition graph: cannot 
deepCopy()");
+        }
+        try {
+            Condition copied = new Condition();
+            if (this.conditionType != null) {
+                copied.setConditionType(this.conditionType);
+            } else if (this.conditionTypeId != null) {
+                copied.setConditionTypeId(this.conditionTypeId);
+            }
+
+            // Deep copy parameter values
+            Map<String, Object> copiedParams = new HashMap<>();
+            if (this.parameterValues != null) {
+                for (Map.Entry<String, Object> entry : 
this.parameterValues.entrySet()) {
+                    Object value = entry.getValue();
+                    if (value instanceof Condition) {
+                        copiedParams.put(entry.getKey(), ((Condition) 
value).deepCopy(copying));
+                    } else if (value instanceof Collection) {
+                        Collection<?> collection = (Collection<?>) value;
+                        Collection<Object> copiedCollection;
+                        if (collection instanceof List) {
+                            copiedCollection = new ArrayList<>();
+                        } else {
+                            copiedCollection = new LinkedHashSet<>();
+                        }
+                        for (Object item : collection) {
+                            if (item instanceof Condition) {
+                                copiedCollection.add(((Condition) 
item).deepCopy(copying));
+                            } else {
+                                copiedCollection.add(item);
+                            }
+                        }
+                        copiedParams.put(entry.getKey(), copiedCollection);
+                    } else {
+                        copiedParams.put(entry.getKey(), value);
+                    }
+                }
+            }
+            copied.setParameterValues(copiedParams);
+
+            return copied;
+        } finally {
+            copying.remove(this);
+        }
     }
 
     @Override
     public String toString() {
-        final StringBuilder sb = new StringBuilder("Condition{");
-        sb.append("conditionType=").append(conditionType);
-        sb.append(", conditionTypeId='").append(conditionTypeId).append('\'');
-        sb.append(", parameterValues=").append(parameterValues);
-        sb.append('}');
-        return sb.toString();
+        Map<String, Object> map = toYaml();
+        return YamlUtils.format(map);
     }
 }
diff --git 
a/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java 
b/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
index c8a43225c..3d22c00a3 100644
--- a/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
+++ b/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
@@ -20,10 +20,15 @@ package org.apache.unomi.api.conditions;
 import org.apache.unomi.api.Metadata;
 import org.apache.unomi.api.MetadataItem;
 import org.apache.unomi.api.Parameter;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
 
 import javax.xml.bind.annotation.XmlElement;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.*;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
 
 /**
  * ConditionTypes define new conditions that can be applied to items (for 
example to decide whether a rule needs to be triggered or if a profile is 
considered as taking part in a
@@ -31,7 +36,7 @@ import java.util.List;
  * optimized by coding it. They may also be defined as combination of other 
conditions. A simple condition  could be: “User is male”, while a more generic 
condition with
  * parameters may test whether a given property has a specific value: “User 
property x has value y”.
  */
-public class ConditionType extends MetadataItem  {
+public class ConditionType extends MetadataItem implements YamlConvertible {
     public static final String ITEM_TYPE = "conditionType";
 
     private static final long serialVersionUID = -6965481691241954969L;
@@ -142,4 +147,37 @@ public class ConditionType extends MetadataItem  {
     public int hashCode() {
         return itemId.hashCode();
     }
+
+    /**
+     * Converts this condition type to a Map structure for YAML output.
+     * Implements YamlConvertible interface with circular reference detection.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this condition type
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlMapBuilder.create()
+                .put("parentCondition", "<max depth exceeded>")
+                .put("parameters", "<max depth exceeded>")
+                .build();
+        }
+        if (visited != null && visited.contains(this)) {
+            return circularRef();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        visitedSet.add(this);
+        try {
+            return YamlMapBuilder.create()
+                .mergeObject(super.toYaml(visitedSet, maxDepth))
+                .putIfNotNull("conditionEvaluator", conditionEvaluator)
+                .putIfNotNull("queryBuilder", queryBuilder)
+                .putIfNotNull("parentCondition", parentCondition != null ? 
toYamlValue(parentCondition, visitedSet, maxDepth - 1) : null)
+                .putIfNotEmpty("parameters", parameters != null ? 
(Collection<?>) toYamlValue(parameters, visitedSet, maxDepth - 1) : null)
+                .build();
+        } finally {
+            visitedSet.remove(this);
+        }
+    }
 }
diff --git a/api/src/main/java/org/apache/unomi/api/goals/Goal.java 
b/api/src/main/java/org/apache/unomi/api/goals/Goal.java
index 1b2ff0e88..d2cac5db8 100644
--- a/api/src/main/java/org/apache/unomi/api/goals/Goal.java
+++ b/api/src/main/java/org/apache/unomi/api/goals/Goal.java
@@ -21,6 +21,15 @@ import org.apache.unomi.api.Metadata;
 import org.apache.unomi.api.MetadataItem;
 import org.apache.unomi.api.campaigns.Campaign;
 import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
 
 /**
  * A tracked activity / action that can be accomplished by site (scope) 
visitors. These are tracked in general because they relate to specific business 
objectives or are
@@ -32,7 +41,7 @@ import org.apache.unomi.api.conditions.Condition;
  * <li>audience filtering: any visitor is considered for scope-level goals 
while campaign-level goals only consider visitors who match the campaign's 
conditions
  * </ul>
  */
-public class Goal extends MetadataItem {
+public class Goal extends MetadataItem implements YamlConvertible {
     public static final String ITEM_TYPE = "goal";
     private static final long serialVersionUID = 6131648013470949983L;
     private Condition startEvent;
@@ -87,4 +96,37 @@ public class Goal extends MetadataItem {
     public void setCampaignId(String campaignId) {
         this.campaignId = campaignId;
     }
+
+    /**
+     * Converts this goal to a Map structure for YAML output.
+     * Implements YamlConvertible interface with circular reference detection.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this goal
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlMapBuilder.create()
+                .put("startEvent", "<max depth exceeded>")
+                .put("targetEvent", "<max depth exceeded>")
+                .put("campaignId", campaignId)
+                .build();
+        }
+        if (visited != null && visited.contains(this)) {
+            return circularRef();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        visitedSet.add(this);
+        try {
+            return YamlMapBuilder.create()
+                .mergeObject(super.toYaml(visitedSet, maxDepth))
+                .putIfNotNull("startEvent", startEvent != null ? 
toYamlValue(startEvent, visitedSet, maxDepth - 1) : null)
+                .putIfNotNull("targetEvent", targetEvent != null ? 
toYamlValue(targetEvent, visitedSet, maxDepth - 1) : null)
+                .putIfNotNull("campaignId", campaignId)
+                .build();
+        } finally {
+            visitedSet.remove(this);
+        }
+    }
 }
diff --git a/api/src/main/java/org/apache/unomi/api/rules/Rule.java 
b/api/src/main/java/org/apache/unomi/api/rules/Rule.java
index 0aedfb123..cd6f751e5 100644
--- a/api/src/main/java/org/apache/unomi/api/rules/Rule.java
+++ b/api/src/main/java/org/apache/unomi/api/rules/Rule.java
@@ -20,10 +20,16 @@ package org.apache.unomi.api.rules;
 import org.apache.unomi.api.*;
 import org.apache.unomi.api.actions.Action;
 import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
 
-import java.util.List;
+import java.util.*;
 import java.util.stream.Collectors;
 
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
+
 /**
  * A conditional set of actions to be executed in response to incoming events. 
Triggering of rules is guarded by a condition: the rule is only triggered if 
the associated
  * condition ({@link #getCondition()}) is satisfied. Once a rule triggers, a 
list of actions ({@link #getActions()} can be performed as consequences.
@@ -34,7 +40,7 @@ import java.util.stream.Collectors;
  * We could also specify a priority for our rule in case it needs to be 
executed before other ones when similar conditions match. This is accomplished 
using the
  * {@link #getPriority()} property.
  */
-public class Rule extends MetadataItem {
+public class Rule extends MetadataItem implements YamlConvertible {
 
     /**
      * The Rule ITEM_TYPE.
@@ -197,4 +203,41 @@ public class Rule extends MetadataItem {
     public void setPriority(int priority) {
         this.priority = priority;
     }
+
+    /**
+     * Converts this rule to a Map structure for YAML output.
+     * Implements YamlConvertible interface with circular reference detection.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this rule
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlMapBuilder.create()
+                .put("condition", "<max depth exceeded>")
+                .put("actions", "<max depth exceeded>")
+                .put("priority", priority)
+                .build();
+        }
+        if (visited != null && visited.contains(this)) {
+            return circularRef();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        visitedSet.add(this);
+        try {
+            return YamlMapBuilder.create()
+                .mergeObject(super.toYaml(visitedSet, maxDepth))
+                .putIfNotNull("condition", condition != null ? 
toYamlValue(condition, visitedSet, maxDepth - 1) : null)
+                .putIfNotEmpty("actions", actions != null ? (Collection<?>) 
toYamlValue(actions, visitedSet, maxDepth - 1) : null)
+                .putIfNotEmpty("linkedItems", linkedItems)
+                .putIf("raiseEventOnlyOnceForProfile", true, 
raiseEventOnlyOnceForProfile)
+                .putIf("raiseEventOnlyOnceForSession", true, 
raiseEventOnlyOnceForSession)
+                .putIf("raiseEventOnlyOnce", true, raiseEventOnlyOnce)
+                .putIf("priority", priority, priority != 0)
+                .build();
+        } finally {
+            visitedSet.remove(this);
+        }
+    }
 }
diff --git a/api/src/main/java/org/apache/unomi/api/segments/Scoring.java 
b/api/src/main/java/org/apache/unomi/api/segments/Scoring.java
index 6018fb376..b32aa8ab0 100644
--- a/api/src/main/java/org/apache/unomi/api/segments/Scoring.java
+++ b/api/src/main/java/org/apache/unomi/api/segments/Scoring.java
@@ -21,14 +21,20 @@ import org.apache.unomi.api.Item;
 import org.apache.unomi.api.Metadata;
 import org.apache.unomi.api.MetadataItem;
 import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
 
-import java.util.List;
+import java.util.*;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
 
 /**
  * A set of conditions associated with a value to assign to {@link Profile}s 
when matching so that the associated users can be scored along that
  * dimension. Each {@link ScoringElement} is evaluated and matching profiles' 
scores are incremented with the associated value.
  */
-public class Scoring extends MetadataItem {
+public class Scoring extends MetadataItem implements YamlConvertible {
     /**
      * The Scoring ITEM_TYPE.
      *
@@ -71,4 +77,33 @@ public class Scoring extends MetadataItem {
         this.elements = elements;
     }
 
+    /**
+     * Converts this scoring to a Map structure for YAML output.
+     * Implements YamlConvertible interface with circular reference detection.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this scoring
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlMapBuilder.create()
+                .put("elements", "<max depth exceeded>")
+                .build();
+        }
+        if (visited != null && visited.contains(this)) {
+            return circularRef();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        visitedSet.add(this);
+        try {
+            return YamlMapBuilder.create()
+                .mergeObject(super.toYaml(visitedSet, maxDepth))
+                .putIfNotEmpty("elements", elements != null ? (Collection<?>) 
toYamlValue(elements, visitedSet, maxDepth - 1) : null)
+                .build();
+        } finally {
+            visitedSet.remove(this);
+        }
+    }
+
 }
diff --git 
a/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java 
b/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java
index 1af5147fb..ab79d132a 100644
--- a/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java
+++ b/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java
@@ -18,13 +18,21 @@
 package org.apache.unomi.api.segments;
 
 import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
 
 import java.io.Serializable;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
 
 /**
  * A scoring dimension along profiles can be evaluated and associated value to 
be assigned.
  */
-public class ScoringElement implements Serializable {
+public class ScoringElement implements Serializable, YamlConvertible {
     private Condition condition;
     private int value;
 
@@ -69,4 +77,40 @@ public class ScoringElement implements Serializable {
     public void setValue(int value) {
         this.value = value;
     }
+
+    /**
+     * Converts this scoring element to a Map structure for YAML output.
+     * Implements YamlConvertible interface with circular reference detection.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this scoring element
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlMapBuilder.create()
+                .put("condition", "<max depth exceeded>")
+                .put("value", value)
+                .build();
+        }
+        if (visited != null && visited.contains(this)) {
+            return circularRef();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        visitedSet.add(this);
+        try {
+            return YamlMapBuilder.create()
+                .putIfNotNull("condition", condition != null ? 
toYamlValue(condition, visitedSet, maxDepth - 1) : null)
+                .put("value", value)
+                .build();
+        } finally {
+            visitedSet.remove(this);
+        }
+    }
+
+    @Override
+    public String toString() {
+        Map<String, Object> map = toYaml();
+        return YamlUtils.format(map);
+    }
 }
diff --git a/api/src/main/java/org/apache/unomi/api/segments/Segment.java 
b/api/src/main/java/org/apache/unomi/api/segments/Segment.java
index 4e0d33830..126279c15 100644
--- a/api/src/main/java/org/apache/unomi/api/segments/Segment.java
+++ b/api/src/main/java/org/apache/unomi/api/segments/Segment.java
@@ -22,13 +22,22 @@ import org.apache.unomi.api.Metadata;
 import org.apache.unomi.api.MetadataItem;
 import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
 
 /**
  * A dynamically evaluated group of similar profiles in order to categorize 
the associated users. To be considered part of a given segment, users must 
satisfies
  * the segment’s condition. If they match, users are automatically added to 
the segment. Similarly, if at any given point during, they cease to satisfy the 
segment’s condition,
  * they are automatically removed from it.
  */
-public class Segment extends MetadataItem {
+public class Segment extends MetadataItem implements YamlConvertible {
 
     /**
      * The Segment ITEM_TYPE.
@@ -72,4 +81,33 @@ public class Segment extends MetadataItem {
         this.condition = condition;
     }
 
+    /**
+     * Converts this segment to a Map structure for YAML output.
+     * Implements YamlConvertible interface with circular reference detection.
+     *
+     * @param visited set of already visited objects to prevent infinite 
recursion (may be null)
+     * @return a Map representation of this segment
+     */
+    @Override
+    public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+        if (maxDepth <= 0) {
+            return YamlMapBuilder.create()
+                .put("condition", "<max depth exceeded>")
+                .build();
+        }
+        if (visited != null && visited.contains(this)) {
+            return circularRef();
+        }
+        final Set<Object> visitedSet = visited != null ? visited : 
YamlUtils.newIdentityVisitedSet();
+        visitedSet.add(this);
+        try {
+            return YamlMapBuilder.create()
+                .mergeObject(super.toYaml(visitedSet, maxDepth))
+                .putIfNotNull("condition", condition != null ? 
toYamlValue(condition, visitedSet, maxDepth - 1) : null)
+                .build();
+        } finally {
+            visitedSet.remove(this);
+        }
+    }
+
 }
diff --git a/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java 
b/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java
new file mode 100644
index 000000000..0755fbe6d
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java
@@ -0,0 +1,330 @@
+/*
+ * 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.unomi.api.utils;
+
+import org.yaml.snakeyaml.DumperOptions;
+import org.yaml.snakeyaml.Yaml;
+
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * YAML utilities using SnakeYaml with fluent API wrapper.
+ * Provides utilities for building YAML structures and formatting them via 
SnakeYaml.
+ */
+public class YamlUtils {
+    // ThreadLocal because SnakeYAML's Yaml is not thread-safe
+    private static final ThreadLocal<Yaml> YAML_INSTANCE = 
ThreadLocal.withInitial(() -> {
+        DumperOptions options = new DumperOptions();
+        options.setIndent(2);
+        options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
+        options.setPrettyFlow(true);
+        options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN);
+        return new Yaml(options);
+    });
+
+    /**
+     * Interface for objects that can convert themselves to YAML Map 
structures.
+     */
+    public interface YamlConvertible {
+        /**
+         * Converts this object to a Map structure for YAML output with depth 
limiting.
+         * This method accepts an optional visited set to detect circular 
references and a max depth
+         * to prevent StackOverflowError from extremely deep nested structures.
+         *
+         * @param visited optional set of visited objects to detect circular 
references (may be null).
+         *                When non-null, use identity semantics (e.g. {@link 
YamlUtils#newIdentityVisitedSet()})
+         *                so cycles are detected by object identity, not 
{@code equals}.
+         * @param maxDepth maximum recursion depth (prevents 
StackOverflowError from deep nesting)
+         * @return a Map representation of this object
+         */
+        Map<String, Object> toYaml(Set<Object> visited, int maxDepth);
+
+        /**
+         * Converts this object to a Map structure for YAML output.
+         * This method accepts an optional visited set to detect circular 
references.
+         * Uses a default max depth of 20 to prevent StackOverflowError.
+         *
+         * @param visited optional set of visited objects to detect circular 
references (may be null)
+         * @return a Map representation of this object
+         */
+        default Map<String, Object> toYaml(Set<Object> visited) {
+            return toYaml(visited, 20);
+        }
+
+        /**
+         * Converts this object to a Map structure for YAML output.
+         * This is a convenience method that calls toYaml(null, 20).
+         *
+         * @return a Map representation of this object
+         */
+        default Map<String, Object> toYaml() {
+            return toYaml(null, 20);
+        }
+    }
+
+    /**
+     * Fluent builder for creating YAML Map structures.
+     * Provides chaining methods to avoid repeating the map variable.
+     */
+    public static class YamlMapBuilder {
+        private final Map<String, Object> map;
+
+        private YamlMapBuilder() {
+            this.map = new LinkedHashMap<>();
+        }
+
+        /**
+         * Creates a new builder instance.
+         *
+         * @return a new YamlMapBuilder
+         */
+        public static YamlMapBuilder create() {
+            return new YamlMapBuilder();
+        }
+
+        /**
+         * Adds a field if the value is not null.
+         *
+         * @param key the key (must not be null)
+         * @param value the value (only added if not null)
+         * @return this builder for chaining
+         * @throws NullPointerException if key is null
+         */
+        public YamlMapBuilder putIfNotNull(String key, Object value) {
+            if (key == null) {
+                throw new NullPointerException("Key must not be null");
+            }
+            if (value != null) {
+                map.put(key, value);
+            }
+            return this;
+        }
+
+        /**
+         * Adds a field if the condition is true.
+         *
+         * @param key the key (must not be null)
+         * @param value the value (only added if condition is true)
+         * @param condition the condition
+         * @return this builder for chaining
+         * @throws NullPointerException if key is null
+         */
+        public YamlMapBuilder putIf(String key, Object value, boolean 
condition) {
+            if (key == null) {
+                throw new NullPointerException("Key must not be null");
+            }
+            if (condition) {
+                map.put(key, value);
+            }
+            return this;
+        }
+
+        /**
+         * Adds a field unconditionally.
+         *
+         * @param key the key (must not be null)
+         * @param value the value
+         * @return this builder for chaining
+         * @throws NullPointerException if key is null
+         */
+        public YamlMapBuilder put(String key, Object value) {
+            if (key == null) {
+                throw new NullPointerException("Key must not be null");
+            }
+            map.put(key, value);
+            return this;
+        }
+
+        /**
+         * Adds a field if the collection is not null and not empty.
+         *
+         * @param key the key (must not be null)
+         * @param collection the collection (only added if not null and not 
empty)
+         * @return this builder for chaining
+         * @throws NullPointerException if key is null
+         */
+        public YamlMapBuilder putIfNotEmpty(String key, 
java.util.Collection<?> collection) {
+            if (key == null) {
+                throw new NullPointerException("Key must not be null");
+            }
+            if (collection != null && !collection.isEmpty()) {
+                map.put(key, collection);
+            }
+            return this;
+        }
+
+        /**
+         * Merges all fields from a Map into this builder.
+         * This is useful for inheritance where subclasses want to include 
parent class fields.
+         * 
+         * Usage in subclasses:
+         * <pre>
+         * return YamlMapBuilder.create()
+         *     .mergeObject(super.toYaml(visitedSet))
+         *     .putIfNotNull("field", value)
+         *     .build();
+         * </pre>
+         *
+         * @param objectMap the Map containing fields to merge (may be null, 
in which case nothing is merged)
+         * @return this builder for chaining
+         */
+        public YamlMapBuilder mergeObject(Map<String, Object> objectMap) {
+            if (objectMap != null) {
+                objectMap.forEach(map::put);
+            }
+            return this;
+        }
+
+        /**
+         * Builds and returns a defensive copy of the map.
+         *
+         * @return a new LinkedHashMap containing the built entries
+         */
+        public Map<String, Object> build() {
+            return new LinkedHashMap<>(map);
+        }
+    }
+
+    /**
+     * Converts a Set to a sorted List for YAML output.
+     *
+     * @param set the set to convert
+     * @return a sorted list, or null if the set is null or empty
+     */
+    public static <T extends Comparable<T>> List<T> setToSortedList(Set<T> 
set) {
+        if (set == null || set.isEmpty()) {
+            return null;
+        }
+        return set.stream().sorted().collect(Collectors.toList());
+    }
+
+    /**
+     * Converts a Set to a sorted List using a mapper function.
+     *
+     * @param set the set to convert
+     * @param mapper the mapper function (must not be null)
+     * @return a sorted list, or null if the set is null or empty
+     * @throws NullPointerException if mapper is null
+     */
+    public static <T, R extends Comparable<R>> List<R> setToSortedList(Set<T> 
set, Function<T, R> mapper) {
+        if (mapper == null) {
+            throw new NullPointerException("Mapper function must not be null");
+        }
+        if (set == null || set.isEmpty()) {
+            return null;
+        }
+        return set.stream().map(mapper).sorted().collect(Collectors.toList());
+    }
+
+    /**
+     * Creates an empty {@link Set} suitable for {@link 
YamlConvertible#toYaml(Set, int)} visited tracking.
+     * The set uses reference identity ({@link IdentityHashMap}), not {@link 
Object#equals(Object) equals},
+     * so distinct object graphs are not mistaken for cycles when types 
override equality.
+     *
+     * @return a new modifiable identity-based set
+     */
+    public static Set<Object> newIdentityVisitedSet() {
+        return Collections.newSetFromMap(new IdentityHashMap<>());
+    }
+
+    /**
+     * Converts a value to YAML-compatible format, handling nested structures.
+     * For objects that implement YamlConvertible, circular reference 
detection is
+     * handled by passing the visited set to their toYaml() implementation.
+     *
+     * @param value the value to convert
+     * @param visited set of visited objects for circular reference detection 
(may be null)
+     * @return the converted value
+     */
+    public static Object toYamlValue(Object value, Set<Object> visited) {
+        return toYamlValue(value, visited, Integer.MAX_VALUE);
+    }
+
+    /**
+     * Converts a value to YAML-compatible format with depth limiting to 
prevent StackOverflowError.
+     * For objects that implement YamlConvertible, circular reference 
detection is
+     * handled by passing the visited set to their toYaml() implementation.
+     *
+     * @param value the value to convert
+     * @param visited set of visited objects for circular reference detection 
(may be null)
+     * @param maxDepth maximum recursion depth (prevents StackOverflowError 
from deep nesting)
+     * @return the converted value, or a placeholder if max depth exceeded
+     */
+    public static Object toYamlValue(Object value, Set<Object> visited, int 
maxDepth) {
+        if (maxDepth <= 0) {
+            return "<max depth exceeded>";
+        }
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof YamlConvertible) {
+            // toYaml already decrements depth; pass maxDepth through 
unchanged for the map-sorting pass
+            Map<String, Object> result = ((YamlConvertible) 
value).toYaml(visited, maxDepth - 1);
+            return toYamlValue(result, visited, maxDepth);
+        }
+        if (value instanceof List) {
+            return ((List<?>) value).stream()
+                .map(item -> toYamlValue(item, visited, maxDepth - 1))
+                .collect(Collectors.toList());
+        }
+        if (value instanceof Map) {
+            Map<?, ?> inputMap = (Map<?, ?>) value;
+            Map<String, Object> result = new LinkedHashMap<>();
+            
+            if (!inputMap.isEmpty()) {
+                // Sort entries alphabetically by key string representation
+                inputMap.entrySet().stream()
+                    .sorted((e1, e2) -> 
String.valueOf(e1.getKey()).compareTo(String.valueOf(e2.getKey())))
+                    .forEach(entry ->
+                        result.put(String.valueOf(entry.getKey()), 
toYamlValue(entry.getValue(), visited, maxDepth - 1)));
+            }
+            return result;
+        }
+        return value;
+    }
+
+
+    /**
+     * Formats a value as YAML using SnakeYaml.
+     * This is a convenience method that delegates to SnakeYaml.
+     *
+     * @param value the value to format
+     * @return YAML string representation
+     */
+    public static String format(Object value) {
+        return YAML_INSTANCE.get().dump(value);
+    }
+
+    /**
+     * Creates a circular reference marker map.
+     *
+     * @return a map indicating a circular reference
+     */
+    public static Map<String, Object> circularRef() {
+        return YamlMapBuilder.create()
+            .put("$ref", "circular")
+            .build();
+    }
+}
diff --git a/api/src/test/java/org/apache/unomi/api/ParameterTest.java 
b/api/src/test/java/org/apache/unomi/api/ParameterTest.java
new file mode 100644
index 000000000..2ed12d0c6
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/ParameterTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.unomi.api;
+
+import org.junit.Test;
+
+import java.util.Map;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for {@link Parameter} YAML and accessors.
+ */
+public class ParameterTest {
+
+    @Test
+    public void testToYamlMaxDepthZeroIncludesExpectedKeys() {
+        Parameter p = new Parameter();
+        p.setId("pid");
+        p.setType("string");
+        p.setMultivalued(true);
+        p.setDefaultValue("def");
+        Map<String, Object> y = p.toYaml(null, 0);
+        assertEquals("pid", y.get("id"));
+        assertEquals("string", y.get("type"));
+        assertTrue(y.containsKey("multivalued"));
+        assertEquals("<max depth exceeded>", y.get("defaultValue"));
+    }
+
+    @Test
+    public void 
testToYamlMaxDepthZeroAddsDefaultValueTruncationMarkerEvenWhenUnset() {
+        Parameter p = new Parameter();
+        p.setMultivalued(false);
+        Map<String, Object> y = p.toYaml(null, 0);
+        assertFalse(y.containsKey("id"));
+        assertFalse(y.containsKey("type"));
+        assertFalse(y.containsKey("multivalued"));
+        assertEquals("<max depth exceeded>", y.get("defaultValue"));
+    }
+
+    @Test
+    public void testToYamlNormalPath() {
+        Parameter p = new Parameter("id1", "number", false);
+        p.setDefaultValue(42);
+        Map<String, Object> y = p.toYaml(null, 10);
+        assertEquals("id1", y.get("id"));
+        assertEquals("number", y.get("type"));
+        assertFalse(y.containsKey("multivalued"));
+        assertEquals(42, y.get("defaultValue"));
+    }
+
+    @Test
+    public void testToYamlMultivaluedTrueAddsFlag() {
+        Parameter p = new Parameter("i", "t", true);
+        Map<String, Object> y = p.toYaml(null, 10);
+        assertEquals(Boolean.TRUE, y.get("multivalued"));
+    }
+
+    @Test
+    public void testToStringIsNonEmptyYaml() {
+        Parameter p = new Parameter("x", "boolean", false);
+        String s = p.toString();
+        assertNotNull(s);
+        assertTrue(s.length() > 0);
+        assertTrue(s.contains("x"));
+    }
+}
diff --git 
a/api/src/test/java/org/apache/unomi/api/conditions/ConditionTest.java 
b/api/src/test/java/org/apache/unomi/api/conditions/ConditionTest.java
new file mode 100644
index 000000000..0fff914cf
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/conditions/ConditionTest.java
@@ -0,0 +1,170 @@
+/*
+ * 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.unomi.api.conditions;
+
+import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.junit.Test;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for {@link Condition} behavior (parameters, YAML, deep copy).
+ */
+public class ConditionTest {
+
+    @Test
+    public void testSetParameterValuesNullReplacesWithEmptyMap() {
+        Condition c = new Condition();
+        c.setConditionTypeId("t");
+        c.getParameterValues().put("k", "v");
+        c.setParameterValues(null);
+        assertNotNull(c.getParameterValues());
+        assertTrue(c.getParameterValues().isEmpty());
+        assertFalse(c.containsParameter("k"));
+        assertNull(c.getParameter("k"));
+    }
+
+    @Test
+    public void testSetParameterAfterClearingParameterValues() {
+        Condition c = new Condition();
+        c.setConditionTypeId("t");
+        c.setParameterValues(null);
+        c.setParameter("x", 1);
+        assertEquals(Integer.valueOf(1), c.getParameter("x"));
+        assertTrue(c.containsParameter("x"));
+    }
+
+    @Test
+    public void testToYamlMaxDepthZeroUsesPlaceholder() {
+        Condition c = new Condition();
+        c.setConditionTypeId("myType");
+        c.getParameterValues().put("p", "v");
+        Map<String, Object> y = c.toYaml(null, 0);
+        assertEquals("myType", y.get("type"));
+        assertEquals("<max depth exceeded>", y.get("parameterValues"));
+    }
+
+    @Test
+    public void testToYamlMaxDepthZeroDefaultTypeWhenIdMissing() {
+        Condition c = new Condition();
+        Map<String, Object> y = c.toYaml(null, 0);
+        assertEquals("Condition", y.get("type"));
+    }
+
+    @Test
+    public void testToYamlWhenAlreadyVisitedReturnsCircularMarker() {
+        Condition c = new Condition();
+        c.setConditionTypeId("t");
+        Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+        visited.add(c);
+        Map<String, Object> y = c.toYaml(visited, 5);
+        assertEquals("circular", y.get("$ref"));
+    }
+
+    @Test
+    public void testToYamlOmitsParameterValuesWhenEmpty() {
+        Condition c = new Condition();
+        c.setConditionTypeId("onlyType");
+        Map<String, Object> y = c.toYaml(null, 10);
+        assertFalse(y.containsKey("parameterValues"));
+    }
+
+    @Test
+    public void testToStringUsesYamlFormat() {
+        Condition c = new Condition();
+        c.setConditionTypeId("ctype");
+        String s = c.toString();
+        assertNotNull(s);
+        assertTrue(s.contains("ctype"));
+    }
+
+    @Test
+    public void testDeepCopyPreservesConditionTypeIdOnly() {
+        Condition c = new Condition();
+        c.setConditionTypeId("idOnly");
+        Condition copy = c.deepCopy();
+        assertNotSame(c, copy);
+        assertEquals("idOnly", copy.getConditionTypeId());
+        assertNull(copy.getConditionType());
+    }
+
+    @Test
+    public void testDeepCopyPreservesConditionTypeReference() {
+        ConditionType ct = new ConditionType(new Metadata("meta-ct"));
+        ct.setItemId("evaluatorType");
+        Condition c = new Condition(ct);
+        Condition copy = c.deepCopy();
+        assertSame(ct, copy.getConditionType());
+        assertEquals("evaluatorType", copy.getConditionTypeId());
+    }
+
+    @Test
+    public void testDeepCopyNestedConditionInSetPreservesSetType() {
+        Condition inner = new Condition();
+        inner.setConditionTypeId("inner");
+        Condition outer = new Condition();
+        outer.setConditionTypeId("outer");
+        Set<Condition> nested = new LinkedHashSet<>();
+        nested.add(inner);
+        outer.getParameterValues().put("conds", nested);
+
+        Condition copy = outer.deepCopy();
+        Object copiedVal = copy.getParameterValues().get("conds");
+        assertTrue(copiedVal instanceof LinkedHashSet);
+        @SuppressWarnings("unchecked")
+        Collection<Condition> col = (Collection<Condition>) copiedVal;
+        assertEquals(1, col.size());
+        Condition copyInner = col.iterator().next();
+        assertNotSame(inner, copyInner);
+        assertEquals("inner", copyInner.getConditionTypeId());
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testDeepCopyRejectsSelfReferenceInParameterMap() {
+        Condition c = new Condition();
+        c.setConditionTypeId("self");
+        c.getParameterValues().put("me", c);
+        c.deepCopy();
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testDeepCopyRejectsSelfInSingletonCollection() {
+        Condition c = new Condition();
+        c.setConditionTypeId("self");
+        c.getParameterValues().put("list", Collections.singletonList(c));
+        c.deepCopy();
+    }
+
+    @Test
+    public void testEqualsAndHashCode() {
+        Condition a = new Condition();
+        a.setConditionTypeId("t");
+        a.getParameterValues().put("k", 1);
+        Condition b = new Condition();
+        b.setConditionTypeId("t");
+        b.getParameterValues().put("k", 1);
+        assertEquals(a, b);
+        assertEquals(a.hashCode(), b.hashCode());
+    }
+}
diff --git a/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java 
b/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java
new file mode 100644
index 000000000..af4150512
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java
@@ -0,0 +1,729 @@
+/*
+ * 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.unomi.api.utils;
+
+import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.actions.Action;
+import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.rules.Rule;
+import org.junit.Test;
+
+import java.util.*;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for YamlUtils fluent API.
+ * Tests focus on our fluent API, not SnakeYaml's implementation.
+ */
+public class YamlUtilsTest {
+
+    @Test
+    public void testYamlMapBuilderCreate() {
+        YamlUtils.YamlMapBuilder builder = YamlUtils.YamlMapBuilder.create();
+        assertNotNull("Builder should be created", builder);
+    }
+
+    @Test
+    public void testYamlMapBuilderPut() {
+        Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+            .put("key1", "value1")
+            .put("key2", 42)
+            .build();
+        assertEquals("First value should be set", "value1", map.get("key1"));
+        assertEquals("Second value should be set", 42, map.get("key2"));
+    }
+
+    @Test
+    public void testYamlMapBuilderPutIfNotNull() {
+        Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+            .putIfNotNull("key1", "value1")
+            .putIfNotNull("key2", null)
+            .putIfNotNull("key3", "value3")
+            .build();
+        assertEquals("Non-null value should be set", "value1", 
map.get("key1"));
+        assertFalse("Null value should not be set", map.containsKey("key2"));
+        assertEquals("Another non-null value should be set", "value3", 
map.get("key3"));
+    }
+
+    @Test
+    public void testYamlMapBuilderPutIf() {
+        Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+            .putIf("key1", "value1", true)
+            .putIf("key2", "value2", false)
+            .putIf("key3", "value3", true)
+            .build();
+        assertEquals("Value with true condition should be set", "value1", 
map.get("key1"));
+        assertFalse("Value with false condition should not be set", 
map.containsKey("key2"));
+        assertEquals("Another value with true condition should be set", 
"value3", map.get("key3"));
+    }
+
+    @Test
+    public void testYamlMapBuilderPutIfNotEmpty() {
+        Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+            .putIfNotEmpty("key1", Arrays.asList("a", "b"))
+            .putIfNotEmpty("key2", Collections.emptyList())
+            .putIfNotEmpty("key3", null)
+            .putIfNotEmpty("key4", Arrays.asList("c"))
+            .build();
+        assertTrue("Non-empty collection should be set", 
map.containsKey("key1"));
+        assertFalse("Empty collection should not be set", 
map.containsKey("key2"));
+        assertFalse("Null collection should not be set", 
map.containsKey("key3"));
+        assertTrue("Another non-empty collection should be set", 
map.containsKey("key4"));
+    }
+
+    @Test
+    public void testYamlMapBuilderChaining() {
+        Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+            .put("a", 1)
+            .putIfNotNull("b", "value")
+            .putIf("c", 3, true)
+            .putIfNotEmpty("d", Arrays.asList(1, 2))
+            .build();
+        assertEquals("All valid entries should be added", 4, map.size());
+    }
+
+    @Test
+    public void testYamlMapBuilderNullKeyThrowsException() {
+        try {
+            YamlUtils.YamlMapBuilder.create().put(null, "value");
+            fail("Null key should throw NullPointerException");
+        } catch (NullPointerException e) {
+            // Expected
+        }
+    }
+
+    @Test
+    public void testYamlMapBuilderNullKeyInPutIfNotNull() {
+        try {
+            YamlUtils.YamlMapBuilder.create().putIfNotNull(null, "value");
+            fail("Null key in putIfNotNull should throw NullPointerException");
+        } catch (NullPointerException e) {
+            // Expected
+        }
+    }
+
+    @Test
+    public void testYamlMapBuilderNullKeyInPutIf() {
+        try {
+            YamlUtils.YamlMapBuilder.create().putIf(null, "value", true);
+            fail("Null key in putIf should throw NullPointerException");
+        } catch (NullPointerException e) {
+            // Expected
+        }
+    }
+
+    @Test
+    public void testYamlMapBuilderNullKeyInPutIfNotEmpty() {
+        try {
+            YamlUtils.YamlMapBuilder.create().putIfNotEmpty(null, 
Arrays.asList(1));
+            fail("Null key in putIfNotEmpty should throw 
NullPointerException");
+        } catch (NullPointerException e) {
+            // Expected
+        }
+    }
+
+    @Test
+    public void testYamlMapBuilderBuildReturnsNewMap() {
+        YamlUtils.YamlMapBuilder builder = YamlUtils.YamlMapBuilder.create();
+        builder.put("key", "value");
+        Map<String, Object> map1 = builder.build();
+        Map<String, Object> map2 = builder.build();
+        assertNotSame("Each build() should return a new map", map1, map2);
+        assertEquals("Both maps should have same content", map1, map2);
+    }
+
+    @Test
+    public void testSetToSortedList() {
+        Set<String> set = new LinkedHashSet<>(Arrays.asList("zebra", "apple", 
"banana"));
+        List<String> result = YamlUtils.setToSortedList(set);
+        assertNotNull("Result should not be null", result);
+        assertEquals("Set should be converted to sorted list", 
Arrays.asList("apple", "banana", "zebra"), result);
+    }
+
+    @Test
+    public void testSetToSortedListNull() {
+        List<String> result = YamlUtils.setToSortedList((Set<String>) null);
+        assertNull("Null set should return null", result);
+    }
+
+    @Test
+    public void testSetToSortedListEmpty() {
+        List<String> result = 
YamlUtils.setToSortedList(Collections.<String>emptySet());
+        assertNull("Empty set should return null", result);
+    }
+
+    @Test
+    public void testSetToSortedListWithMapper() {
+        Set<Integer> set = new LinkedHashSet<>(Arrays.asList(3, 1, 2));
+        List<String> result = YamlUtils.setToSortedList(set, String::valueOf);
+        assertNotNull("Result should not be null", result);
+        assertEquals("Set should be converted to sorted list using mapper", 
Arrays.asList("1", "2", "3"), result);
+    }
+
+    @Test
+    public void testSetToSortedListWithMapperNull() {
+        try {
+            YamlUtils.<Integer, 
String>setToSortedList(Collections.singleton(1), null);
+            fail("Null mapper should throw NullPointerException");
+        } catch (NullPointerException e) {
+            // Expected
+        }
+    }
+
+    @Test
+    public void testSetToSortedListWithMapperNullSet() {
+        List<String> result = YamlUtils.setToSortedList(null, String::valueOf);
+        assertNull("Null set should return null even with mapper", result);
+    }
+
+    @Test
+    public void testToYamlValueWithYamlConvertible() {
+        YamlUtils.YamlConvertible convertible = (visited, maxDepth) -> {
+            Map<String, Object> map = new LinkedHashMap<>();
+            map.put("test", "value");
+            return map;
+        };
+        Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+        Object result = YamlUtils.toYamlValue(convertible, visited);
+        assertTrue("YamlConvertible should be converted to Map", result 
instanceof Map);
+        Map<?, ?> map = (Map<?, ?>) result;
+        assertEquals("Converted map should contain test value", "value", 
map.get("test"));
+    }
+
+    @Test
+    public void testToYamlValueWithList() {
+        List<Object> list = Arrays.asList("a", "b", "c");
+        Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+        Object result = YamlUtils.toYamlValue(list, visited);
+        assertTrue("List should remain a List", result instanceof List);
+        assertEquals("List should be unchanged", list, result);
+    }
+
+    @Test
+    public void testToYamlValueWithMap() {
+        Map<String, Object> map = new LinkedHashMap<>();
+        map.put("key", "value");
+        Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+        Object result = YamlUtils.toYamlValue(map, visited);
+        assertTrue("Map should remain a Map", result instanceof Map);
+        assertEquals("Map should contain key-value", "value", ((Map<?, ?>) 
result).get("key"));
+    }
+
+    @Test
+    public void testToYamlValueWithNull() {
+        Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+        Object result = YamlUtils.toYamlValue(null, visited);
+        assertNull("Null should return null", result);
+    }
+
+    @Test
+    public void testToYamlValueWithPrimitive() {
+        Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+        Object result = YamlUtils.toYamlValue(42, visited);
+        assertEquals("Primitive should remain unchanged", 42, result);
+    }
+
+    @Test
+    public void testCircularRef() {
+        Map<String, Object> result = YamlUtils.circularRef();
+        assertNotNull("circularRef should return a map", result);
+        assertEquals("Should contain $ref: circular", "circular", 
result.get("$ref"));
+        assertEquals("Should have only one entry", 1, result.size());
+    }
+
+    @Test
+    public void testFormatBasic() {
+        // Just verify format() works - we don't test SnakeYaml's output format
+        Map<String, Object> map = new LinkedHashMap<>();
+        map.put("key", "value");
+        String result = YamlUtils.format(map);
+        assertNotNull("Format should return a string", result);
+        assertTrue("Format should contain key", result.contains("key"));
+        assertTrue("Format should contain value", result.contains("value"));
+    }
+
+    // ========== Circular Reference Detection Tests ==========
+
+    @Test
+    public void testRuleInheritanceChainNoCircularRef() {
+        // Test that Rule -> MetadataItem -> Item inheritance chain doesn't 
produce false circular refs
+        Rule rule = new Rule();
+        rule.setItemId("test-rule");
+        Metadata metadata = new Metadata("test-rule");
+        metadata.setScope("systemscope");
+        rule.setMetadata(metadata);
+        
+        Condition condition = new Condition();
+        condition.setConditionTypeId("testCondition");
+        rule.setCondition(condition);
+        
+        Map<String, Object> result = rule.toYaml(null);
+        assertNotNull("Rule should serialize to YAML", result);
+        assertFalse("Should not contain circular reference marker", 
result.containsKey("$ref"));
+        assertTrue("Should contain condition", 
result.containsKey("condition"));
+        assertTrue("Should contain itemId from Item parent", 
result.containsKey("itemId"));
+        assertTrue("Should contain metadata from MetadataItem parent", 
result.containsKey("metadata"));
+    }
+
+    @Test
+    public void testRuleWithCircularReferenceInCondition() {
+        // Test that a real circular reference (Rule referenced in condition's 
parameterValues) is detected
+        Rule rule = new Rule();
+        rule.setItemId("test-rule");
+        Metadata metadata = new Metadata("test-rule");
+        rule.setMetadata(metadata);
+        
+        Condition condition = new Condition();
+        condition.setConditionTypeId("testCondition");
+        // Create a circular reference: condition's parameterValues contains 
the rule itself
+        condition.getParameterValues().put("referencedRule", rule);
+        rule.setCondition(condition);
+        
+        Map<String, Object> result = rule.toYaml(null);
+        assertNotNull("Rule should serialize to YAML", result);
+        assertTrue("Should contain condition", 
result.containsKey("condition"));
+        
+        // Check that the circular reference is detected in the condition's 
parameterValues
+        Map<String, Object> conditionMap = (Map<String, Object>) 
result.get("condition");
+        assertNotNull("Condition should be serialized", conditionMap);
+        Map<String, Object> paramValues = (Map<String, Object>) 
conditionMap.get("parameterValues");
+        assertNotNull("Parameter values should exist", paramValues);
+        Map<String, Object> circularRef = (Map<String, Object>) 
paramValues.get("referencedRule");
+        assertNotNull("Circular reference should be detected", circularRef);
+        assertEquals("Should contain circular reference marker", "circular", 
circularRef.get("$ref"));
+    }
+
+    @Test
+    public void testRuleWithCircularReferenceInActions() {
+        // Test circular reference in actions list
+        Rule rule = new Rule();
+        rule.setItemId("test-rule");
+        Metadata metadata = new Metadata("test-rule");
+        rule.setMetadata(metadata);
+        
+        Action action = new Action();
+        action.setActionTypeId("testAction");
+        // Create circular reference: action's parameterValues contains the 
rule
+        action.getParameterValues().put("triggeringRule", rule);
+        rule.setActions(Collections.singletonList(action));
+        
+        Map<String, Object> result = rule.toYaml(null);
+        assertNotNull("Rule should serialize to YAML", result);
+        assertTrue("Should contain actions", result.containsKey("actions"));
+        
+        List<?> actions = (List<?>) result.get("actions");
+        assertNotNull("Actions list should exist", actions);
+        assertEquals("Should have one action", 1, actions.size());
+        
+        Map<String, Object> actionMap = (Map<String, Object>) actions.get(0);
+        Map<String, Object> paramValues = (Map<String, Object>) 
actionMap.get("parameterValues");
+        assertNotNull("Parameter values should exist", paramValues);
+        Map<String, Object> circularRef = (Map<String, Object>) 
paramValues.get("triggeringRule");
+        assertNotNull("Circular reference should be detected", circularRef);
+        assertEquals("Should contain circular reference marker", "circular", 
circularRef.get("$ref"));
+    }
+
+    @Test
+    public void testNestedCircularReference() {
+        // Test nested circular reference: Rule -> Condition -> nested 
Condition -> Rule
+        Rule rule = new Rule();
+        rule.setItemId("test-rule");
+        Metadata metadata = new Metadata("test-rule");
+        rule.setMetadata(metadata);
+        
+        Condition outerCondition = new Condition();
+        outerCondition.setConditionTypeId("outerCondition");
+        
+        Condition nestedCondition = new Condition();
+        nestedCondition.setConditionTypeId("nestedCondition");
+        // Nested condition references the rule
+        nestedCondition.getParameterValues().put("ruleRef", rule);
+        
+        // Outer condition contains nested condition
+        outerCondition.getParameterValues().put("nested", nestedCondition);
+        rule.setCondition(outerCondition);
+        
+        Map<String, Object> result = rule.toYaml(null);
+        assertNotNull("Rule should serialize to YAML", result);
+        
+        // Navigate through the nested structure
+        Map<String, Object> conditionMap = (Map<String, Object>) 
result.get("condition");
+        Map<String, Object> paramValues = (Map<String, Object>) 
conditionMap.get("parameterValues");
+        Map<String, Object> nestedConditionMap = (Map<String, Object>) 
paramValues.get("nested");
+        Map<String, Object> nestedParamValues = (Map<String, Object>) 
nestedConditionMap.get("parameterValues");
+        Map<String, Object> circularRef = (Map<String, Object>) 
nestedParamValues.get("ruleRef");
+        
+        assertNotNull("Circular reference should be detected in nested 
structure", circularRef);
+        assertEquals("Should contain circular reference marker", "circular", 
circularRef.get("$ref"));
+    }
+
+    @Test
+    public void testMultipleCircularReferences() {
+        // Test multiple circular references to the same object
+        Rule rule = new Rule();
+        rule.setItemId("test-rule");
+        Metadata metadata = new Metadata("test-rule");
+        rule.setMetadata(metadata);
+        
+        Condition condition = new Condition();
+        condition.setConditionTypeId("testCondition");
+        // Multiple references to the same rule
+        condition.getParameterValues().put("rule1", rule);
+        condition.getParameterValues().put("rule2", rule);
+        condition.getParameterValues().put("rule3", rule);
+        rule.setCondition(condition);
+        
+        Map<String, Object> result = rule.toYaml(null);
+        Map<String, Object> conditionMap = (Map<String, Object>) 
result.get("condition");
+        Map<String, Object> paramValues = (Map<String, Object>) 
conditionMap.get("parameterValues");
+        
+        // All three references should show circular ref
+        for (String key : Arrays.asList("rule1", "rule2", "rule3")) {
+            Map<String, Object> circularRef = (Map<String, Object>) 
paramValues.get(key);
+            assertNotNull("Circular reference should be detected for " + key, 
circularRef);
+            assertEquals("Should contain circular reference marker for " + 
key, "circular", circularRef.get("$ref"));
+        }
+    }
+
+
+    @Test
+    public void testCircularReferenceInList() {
+        // Test circular reference in a list
+        Rule rule = new Rule();
+        rule.setItemId("test-rule");
+        Metadata metadata = new Metadata("test-rule");
+        rule.setMetadata(metadata);
+        
+        Condition condition = new Condition();
+        condition.setConditionTypeId("testCondition");
+        // List containing the rule itself
+        condition.getParameterValues().put("ruleList", Arrays.asList(rule, 
"other", rule));
+        rule.setCondition(condition);
+        
+        Map<String, Object> result = rule.toYaml(null);
+        Map<String, Object> conditionMap = (Map<String, Object>) 
result.get("condition");
+        Map<String, Object> paramValues = (Map<String, Object>) 
conditionMap.get("parameterValues");
+        List<?> ruleList = (List<?>) paramValues.get("ruleList");
+        
+        assertNotNull("Rule list should exist", ruleList);
+        assertEquals("List should have 3 elements", 3, ruleList.size());
+        
+        // First element should be circular ref
+        Map<String, Object> circularRef1 = (Map<String, Object>) 
ruleList.get(0);
+        assertEquals("First element should be circular ref", "circular", 
circularRef1.get("$ref"));
+        
+        // Second element should be string
+        assertEquals("Second element should be string", "other", 
ruleList.get(1));
+        
+        // Third element should also be circular ref
+        Map<String, Object> circularRef2 = (Map<String, Object>) 
ruleList.get(2);
+        assertEquals("Third element should be circular ref", "circular", 
circularRef2.get("$ref"));
+    }
+
+    @Test
+    public void testCircularReferenceInNestedMap() {
+        // Test circular reference in nested map structure
+        Rule rule = new Rule();
+        rule.setItemId("test-rule");
+        Metadata metadata = new Metadata("test-rule");
+        rule.setMetadata(metadata);
+        
+        Condition condition = new Condition();
+        condition.setConditionTypeId("testCondition");
+        // Nested map containing the rule
+        Map<String, Object> level2 = new HashMap<>();
+        level2.put("rule", rule);
+        Map<String, Object> level1 = new HashMap<>();
+        level1.put("level2", level2);
+        Map<String, Object> nestedMap = new HashMap<>();
+        nestedMap.put("level1", level1);
+        condition.getParameterValues().put("nested", nestedMap);
+        rule.setCondition(condition);
+        
+        Map<String, Object> result = rule.toYaml(null);
+        Map<String, Object> conditionMap = (Map<String, Object>) 
result.get("condition");
+        Map<String, Object> paramValues = (Map<String, Object>) 
conditionMap.get("parameterValues");
+        Map<String, Object> nested = (Map<String, Object>) 
paramValues.get("nested");
+        Map<String, Object> nestedLevel1 = (Map<String, Object>) 
nested.get("level1");
+        Map<String, Object> nestedLevel2 = (Map<String, Object>) 
nestedLevel1.get("level2");
+        Map<String, Object> circularRef = (Map<String, Object>) 
nestedLevel2.get("rule");
+        
+        assertNotNull("Circular reference should be detected in nested map", 
circularRef);
+        assertEquals("Should contain circular reference marker", "circular", 
circularRef.get("$ref"));
+    }
+
+    @Test
+    public void testNoFalseCircularRefInInheritance() {
+        // Test that inheritance chain (Rule -> MetadataItem -> Item) doesn't 
create false circular refs
+        // This is the main bug we're fixing
+        Rule rule = new Rule();
+        rule.setItemId("test-rule");
+        Metadata metadata = new Metadata("test-rule");
+        metadata.setScope("systemscope");
+        rule.setMetadata(metadata);
+        
+        Condition condition = new Condition();
+        condition.setConditionTypeId("unavailableConditionType");
+        condition.getParameterValues().put("comparisonOperator", "equals");
+        condition.getParameterValues().put("propertyName", "testProperty");
+        condition.getParameterValues().put("propertyValue", "testValue");
+        rule.setCondition(condition);
+        
+        Action action = new Action();
+        action.setActionTypeId("test");
+        rule.setActions(Collections.singletonList(action));
+        
+        Map<String, Object> result = rule.toYaml(null);
+        
+        // Should NOT contain $ref: circular at the top level
+        assertNotNull("Rule should serialize", result);
+        assertFalse("Should not have false circular reference at top level", 
+                    result.containsKey("$ref") && 
"circular".equals(result.get("$ref")));
+        
+        // Should contain all expected fields from inheritance chain
+        assertTrue("Should contain itemId from Item", 
result.containsKey("itemId"));
+        assertTrue("Should contain itemType from Item", 
result.containsKey("itemType"));
+        assertEquals("itemType should be 'rule'", "rule", 
result.get("itemType"));
+        assertTrue("Should contain metadata from MetadataItem", 
result.containsKey("metadata"));
+        assertTrue("Should contain condition", 
result.containsKey("condition"));
+        assertTrue("Should contain actions", result.containsKey("actions"));
+        
+        // Verify condition structure
+        Map<String, Object> conditionMap = (Map<String, Object>) 
result.get("condition");
+        assertNotNull("Condition should be present", conditionMap);
+        assertEquals("Condition should have correct type", 
"unavailableConditionType", conditionMap.get("type"));
+        
+        // Verify actions structure
+        List<?> actions = (List<?>) result.get("actions");
+        assertNotNull("Actions should be present", actions);
+        assertEquals("Should have one action", 1, actions.size());
+    }
+
+    @Test
+    public void testItemTypeIsAlwaysIncluded() {
+        // Test that itemType is always included in YAML output, even if null
+        // This reflects the actual state of the object
+        Rule rule = new Rule();
+        Metadata metadata = new Metadata("test-id");
+        metadata.setScope("systemscope");
+        rule.setMetadata(metadata);
+        
+        Map<String, Object> result = rule.toYaml(null);
+        
+        // itemType should always be present in output (set in Item 
constructor for Rule)
+        assertTrue("itemType should be included", 
result.containsKey("itemType"));
+        assertEquals("itemType should be 'rule'", "rule", 
result.get("itemType"));
+        
+        // itemId should also always be included
+        assertTrue("itemId should be included", result.containsKey("itemId"));
+    }
+
+    @Test
+    public void testItemIdAndItemTypeIncludedEvenWhenNull() {
+        // Test that itemId and itemType are always included, even when null
+        // This ensures YAML output reflects the actual state of the object
+        Rule rule = new Rule();
+        // Explicitly set itemId and itemType to null to test null handling
+        rule.setItemId(null);
+        rule.setItemType(null);
+        
+        Map<String, Object> result = rule.toYaml(null);
+        
+        // Both should be included even if null
+        assertTrue("itemId should be included even when null", 
result.containsKey("itemId"));
+        assertNull("itemId should be null", result.get("itemId"));
+        
+        assertTrue("itemType should be included even when null", 
result.containsKey("itemType"));
+        assertNull("itemType should be null", result.get("itemType"));
+    }
+
+    @Test
+    public void testItemIdFromMetadata() {
+        // Test that itemId is set from metadata and included in YAML
+        Rule rule = new Rule();
+        Metadata metadata = new Metadata("test-rule-id");
+        metadata.setScope("systemscope");
+        rule.setMetadata(metadata);
+        
+        Map<String, Object> result = rule.toYaml(null);
+        
+        // itemId should be set from metadata.getId()
+        assertTrue("itemId should be included when set from metadata", 
result.containsKey("itemId"));
+        assertEquals("itemId should match metadata id", "test-rule-id", 
result.get("itemId"));
+    }
+
+    @Test
+    public void testVisitedSetIsSharedCorrectly() {
+        // Test that visited set is properly shared across nested calls
+        Rule rule1 = new Rule();
+        rule1.setItemId("rule1");
+        rule1.setMetadata(new Metadata("rule1"));
+        
+        Rule rule2 = new Rule();
+        rule2.setItemId("rule2");
+        rule2.setMetadata(new Metadata("rule2"));
+        
+        // rule1 references rule2, rule2 references rule1 (mutual circular 
reference)
+        Condition condition1 = new Condition();
+        condition1.setConditionTypeId("test");
+        condition1.getParameterValues().put("otherRule", rule2);
+        rule1.setCondition(condition1);
+        
+        Condition condition2 = new Condition();
+        condition2.setConditionTypeId("test");
+        condition2.getParameterValues().put("otherRule", rule1);
+        rule2.setCondition(condition2);
+        
+        // Serialize rule1 - should detect circular ref when it encounters 
rule2 which references rule1
+        Map<String, Object> result1 = rule1.toYaml(null);
+        assertNotNull("Rule1 should serialize", result1);
+        
+        Map<String, Object> conditionMap1 = (Map<String, Object>) 
result1.get("condition");
+        Map<String, Object> paramValues1 = (Map<String, Object>) 
conditionMap1.get("parameterValues");
+        Map<String, Object> rule2Ref = (Map<String, Object>) 
paramValues1.get("otherRule");
+        
+        // rule2 should be serialized, but when it tries to reference rule1, 
it should detect circular ref
+        assertNotNull("Rule2 reference should exist", rule2Ref);
+        // rule2 itself should be fully serialized (not circular), but its 
condition's otherRule should be circular
+        Map<String, Object> conditionMap2 = (Map<String, Object>) 
rule2Ref.get("condition");
+        assertNotNull("Rule2's condition should exist", conditionMap2);
+        Map<String, Object> paramValues2 = (Map<String, Object>) 
conditionMap2.get("parameterValues");
+        Map<String, Object> rule1CircularRef = (Map<String, Object>) 
paramValues2.get("otherRule");
+        assertNotNull("Circular reference to rule1 should be detected", 
rule1CircularRef);
+        assertEquals("Should contain circular reference marker", "circular", 
rule1CircularRef.get("$ref"));
+    }
+
+    @Test
+    public void testConditionDeepCopyCopiesNestedConditions() {
+        Condition inner = new Condition();
+        inner.setConditionTypeId("inner");
+        Condition outer = new Condition();
+        outer.setConditionTypeId("outer");
+        outer.getParameterValues().put("c", inner);
+
+        Condition copy = outer.deepCopy();
+        assertNotSame(outer, copy);
+        Condition copyInner = (Condition) copy.getParameterValues().get("c");
+        assertNotSame(inner, copyInner);
+        assertEquals("inner", copyInner.getConditionTypeId());
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testConditionDeepCopyRejectsCycle() {
+        Condition a = new Condition();
+        a.setConditionTypeId("a");
+        Condition b = new Condition();
+        b.setConditionTypeId("b");
+        a.getParameterValues().put("child", b);
+        b.getParameterValues().put("child", a);
+        a.deepCopy();
+    }
+
+    @Test
+    public void testNewIdentityVisitedSetUsesReferenceIdentity() {
+        Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+        String a = new String("x");
+        String b = new String("x");
+        assertTrue(visited.add(a));
+        assertTrue(visited.add(b));
+        assertEquals("equal strings are distinct objects for identity set", 2, 
visited.size());
+    }
+
+    @Test
+    public void testYamlMapBuilderMergeObjectNullIsNoOp() {
+        Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+            .put("k", "v")
+            .mergeObject(null)
+            .build();
+        assertEquals(1, map.size());
+        assertEquals("v", map.get("k"));
+    }
+
+    @Test
+    public void testYamlMapBuilderMergeObjectCopiesEntries() {
+        Map<String, Object> extra = new LinkedHashMap<>();
+        extra.put("a", 1);
+        extra.put("b", 2);
+        Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+            .put("z", 0)
+            .mergeObject(extra)
+            .build();
+        assertEquals(3, map.size());
+        assertEquals(Integer.valueOf(0), map.get("z"));
+        assertEquals(Integer.valueOf(1), map.get("a"));
+        assertEquals(Integer.valueOf(2), map.get("b"));
+    }
+
+    @Test
+    public void testToYamlValueMaxDepthZeroReturnsPlaceholder() {
+        assertEquals("<max depth exceeded>", YamlUtils.toYamlValue("anything", 
null, 0));
+    }
+
+    @Test
+    public void testToYamlValueEmptyMapWithDepth() {
+        @SuppressWarnings("unchecked")
+        Map<String, Object> out = (Map<String, Object>) 
YamlUtils.toYamlValue(Collections.emptyMap(), null, 5);
+        assertNotNull(out);
+        assertTrue(out.isEmpty());
+    }
+
+    @Test
+    public void testToYamlValueSortsMapKeysLexicographically() {
+        Map<String, Object> in = new LinkedHashMap<>();
+        in.put("z", 1);
+        in.put("a", 2);
+        @SuppressWarnings("unchecked")
+        Map<String, Object> out = (Map<String, Object>) 
YamlUtils.toYamlValue(in, null, 10);
+        assertEquals(Arrays.asList("a", "z"), new ArrayList<>(out.keySet()));
+    }
+
+    @Test
+    public void testToYamlValueNonStringMapKeysBecomeStrings() {
+        Map<Integer, String> in = new HashMap<>();
+        in.put(10, "ten");
+        in.put(2, "two");
+        @SuppressWarnings("unchecked")
+        Map<String, Object> out = (Map<String, Object>) 
YamlUtils.toYamlValue(in, null, 10);
+        assertTrue(out.keySet().stream().allMatch(k -> k instanceof String));
+        assertEquals("two", out.get("2"));
+        assertEquals("ten", out.get("10"));
+    }
+
+    @Test
+    public void testToYamlValueTwoArgDelegatesToUnboundedDepth() {
+        Condition c = new Condition();
+        c.setConditionTypeId("c");
+        c.getParameterValues().put("n", 1);
+        Object result = YamlUtils.toYamlValue(c, 
YamlUtils.newIdentityVisitedSet());
+        assertTrue(result instanceof Map);
+    }
+
+    @Test
+    public void testYamlConvertibleDefaultToYamlWithVisitedOnly() {
+        YamlUtils.YamlConvertible convertible = new 
YamlUtils.YamlConvertible() {
+            @Override
+            public Map<String, Object> toYaml(Set<Object> visited, int 
maxDepth) {
+                Map<String, Object> m = new LinkedHashMap<>();
+                m.put("depth", maxDepth);
+                return m;
+            }
+        };
+        Map<String, Object> map = convertible.toYaml(null);
+        assertEquals(Integer.valueOf(20), map.get("depth"));
+    }
+}
diff --git a/bom/pom.xml b/bom/pom.xml
index 69cb5a051..17b0f3752 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -503,6 +503,26 @@
                 <artifactId>junit</artifactId>
                 <version>${junit.version}</version>
             </dependency>
+            <dependency>
+                <groupId>org.junit.jupiter</groupId>
+                <artifactId>junit-jupiter</artifactId>
+                <version>${junit-jupiter.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.mockito</groupId>
+                <artifactId>mockito-core</artifactId>
+                <version>${mockito.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.mockito</groupId>
+                <artifactId>mockito-junit-jupiter</artifactId>
+                <version>${mockito.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.awaitility</groupId>
+                <artifactId>awaitility</artifactId>
+                <version>${awaitility.version}</version>
+            </dependency>
         </dependencies>
     </dependencyManagement>
 
diff --git a/build.sh b/build.sh
index 94891527d..f5239703a 100755
--- a/build.sh
+++ b/build.sh
@@ -25,7 +25,9 @@ trap 'handle_error $? $LINENO "${BASH_COMMAND:0:200}"' ERR
 handle_error() {
     local exit_code=$1
     local line_no=$2
-    local failed_cmd=$3
+    local bash_lineno=$3
+    local last_command=$4
+    local func_trace=$5
 
     cat << "EOF"
      _____ ____  ____   ___  ____
@@ -36,9 +38,12 @@ handle_error() {
 
 EOF
     echo "Error occurred in:"
-    echo "  Command: $failed_cmd"
+    echo "  Command: $last_command"
     echo "  Line: $line_no"
     echo "  Exit code: $exit_code"
+    if [ ! -z "$func_trace" ]; then
+        echo "  Function trace: $func_trace"
+    fi
     exit $exit_code
 }
 
diff --git a/itests/pom.xml b/itests/pom.xml
index 239ef3015..34f993c4c 100644
--- a/itests/pom.xml
+++ b/itests/pom.xml
@@ -32,6 +32,8 @@
         <unomi.search.engine>elasticsearch</unomi.search.engine>
         <use.opensearch>false</use.opensearch>
         <docker.container.name>itests-opensearch</docker.container.name>
+        <!-- Skip caching for this module only -->
+        <maven.build.cache.skip>true</maven.build.cache.skip>
         <!-- Set to true to keep the search engine container running after 
tests (useful for post-failure inspection) -->
         <it.keepContainer>false</it.keepContainer>
     </properties>
@@ -45,6 +47,14 @@
                 <type>pom</type>
                 <scope>import</scope>
             </dependency>
+            <!-- Override awaitility version for integration tests to use 
older version compatible with hamcrest 1.3 -->
+            <!-- Awaitility 4.0.0+ requires hamcrest 2.1+, but we need 
hamcrest 1.3 for OSGi bundle compatibility -->
+            <!-- Using awaitility 3.1.6 which works with hamcrest 1.3 -->
+            <dependency>
+                <groupId>org.awaitility</groupId>
+                <artifactId>awaitility</artifactId>
+                <version>3.1.6</version>
+            </dependency>
         </dependencies>
     </dependencyManagement>
 
@@ -55,6 +65,16 @@
             <artifactId>common</artifactId>
             <version>${karaf.version}</version>
             <scope>test</scope>
+            <exclusions>
+                <exclusion>
+                    <groupId>ch.qos.logback</groupId>
+                    <artifactId>logback-classic</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>ch.qos.logback</groupId>
+                    <artifactId>logback-core</artifactId>
+                </exclusion>
+            </exclusions>
         </dependency>
         <!-- Define the Apache Karaf version to download and use for the test 
-->
         <!-- We use a released version here to avoid SNAPSHOT resolution -->
@@ -107,7 +127,7 @@
             <groupId>org.apache.servicemix.bundles</groupId>
             <artifactId>org.apache.servicemix.bundles.hamcrest</artifactId>
             <version>1.3_1</version>
-            <scope>runtime</scope>
+            <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>org.apache.httpcomponents</groupId>
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java 
b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
index 66f1de7fc..51368c503 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
@@ -94,7 +94,8 @@ public class PropertiesUpdateActionIT extends BaseIT {
 
         int changes = eventService.send(updateProperties);
         Assert.assertTrue("eventService.send() reported no changes — action 
may not have fired", changes > 0);
-        // Current profile on the event is updated in memory; do not poll 
persistence here.
+        // UpdatePropertiesAction updates event.getProfile() in-memory for the 
current-profile path;
+        // persistence is not guaranteed synchronous here so we assert the 
in-memory state directly.
         Assert.assertEquals("UPDATED FIRST NAME CURRENT PROFILE", 
profile.getProperty("firstName"));
     }
 
diff --git a/pom.xml b/pom.xml
index f7e85f403..e368fba06 100644
--- a/pom.xml
+++ b/pom.xml
@@ -80,6 +80,8 @@
         <snakeyaml.version>2.3</snakeyaml.version>
         <opencsv.version>3.10</opencsv.version>
         <log4j.version>2.19.0</log4j.version>
+        <!-- Used by unomi-api slf4j dependencies; single place to align SLF4J 
for the build -->
+        <slf4j.version>1.7.36</slf4j.version>
         <lucene.version>9.12.2</lucene.version>
         <failsafe.version>2.4.0</failsafe.version>
         <joda-time.version>2.12.7</joda-time.version>
@@ -102,6 +104,9 @@
         <httpclient-osgi.version>4.5.14</httpclient-osgi.version>
         <httpcore-osgi.version>4.4.16</httpcore-osgi.version>
         <junit.version>4.13.2</junit.version>
+        <junit-jupiter.version>5.8.2</junit-jupiter.version>
+        <mockito.version>4.5.1</mockito.version>
+        <awaitility.version>4.2.0</awaitility.version>
         <kafka-client.version>2.2.1</kafka-client.version>
         <st4.version>4.3.4</st4.version>
         <commons-email.version>1.6.0</commons-email.version>
diff --git a/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java 
b/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
index bc7ab9acf..d6257c6f0 100644
--- a/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
+++ b/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
@@ -26,7 +26,7 @@ public class RESTParameter {
     private String id;
     private String type;
     private boolean multivalued = false;
-    private String defaultValue = null;
+    private Object defaultValue = null;
 
     public String getId() {
         return id;
@@ -52,11 +52,11 @@ public class RESTParameter {
         this.multivalued = multivalued;
     }
 
-    public String getDefaultValue() {
+    public Object getDefaultValue() {
         return defaultValue;
     }
 
-    public void setDefaultValue(String defaultValue) {
+    public void setDefaultValue(Object defaultValue) {
         this.defaultValue = defaultValue;
     }
 
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
 
b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
index fe3fe8fb0..8b6e7063c 100644
--- 
a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
@@ -450,7 +450,15 @@ public class RulesServiceImpl implements RulesService, 
EventListenerService, Syn
                     
trackedCondition.getConditionType().getParameters().forEach(parameter -> {
                         try {
                             if (TRACKED_PARAMETER.equals(parameter.getId())) {
-                                
Arrays.stream(StringUtils.split(parameter.getDefaultValue(), 
",")).forEach(trackedParameter -> {
+                                // Parameter#getDefaultValue is Object; null 
must not call toString() (NPE) or be passed to split.
+                                Object defaultValue = 
parameter.getDefaultValue();
+                                if (defaultValue == null) {
+                                    LOGGER.debug(
+                                            "Skipping tracked parameter 
mapping: parameter id={} has null defaultValue for condition type {}",
+                                            parameter.getId(), 
trackedCondition.getConditionType().getItemId());
+                                    return;
+                                }
+                                
Arrays.stream(StringUtils.split(defaultValue.toString(), 
",")).forEach(trackedParameter -> {
                                     String[] param = 
StringUtils.split(StringUtils.trim(trackedParameter), ":");
                                     
trackedParameters.put(StringUtils.trim(param[1]), 
trackedCondition.getParameter(StringUtils.trim(param[0])));
                                 });

Reply via email to