diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java
new file mode 100644
index 00000000..f9a32ad6
--- /dev/null
+++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java
@@ -0,0 +1,70 @@
+package com.cameleer.server.core.runtime;
+
+import com.cameleer.common.model.ApplicationConfig;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeSet;
+import java.util.UUID;
+
+/**
+ * Compares the app's current desired state (JAR + agent config + container config) to the
+ * config snapshot from the last successful deployment, producing a structured dirty result.
+ *
+ *
Pure logic — no IO, no Spring. Safe to unit-test as a POJO.
+ */
+public class DirtyStateCalculator {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ public DirtyStateResult compute(UUID desiredJarVersionId,
+ ApplicationConfig desiredAgentConfig,
+ Map desiredContainerConfig,
+ DeploymentConfigSnapshot snapshot) {
+ List diffs = new ArrayList<>();
+
+ if (snapshot == null) {
+ diffs.add(new DirtyStateResult.Difference("snapshot", "(none)", "(none)"));
+ return new DirtyStateResult(true, diffs);
+ }
+
+ if (!Objects.equals(desiredJarVersionId, snapshot.jarVersionId())) {
+ diffs.add(new DirtyStateResult.Difference("jarVersionId",
+ String.valueOf(desiredJarVersionId), String.valueOf(snapshot.jarVersionId())));
+ }
+
+ compareJson("agentConfig", MAPPER.valueToTree(desiredAgentConfig),
+ MAPPER.valueToTree(snapshot.agentConfig()), diffs);
+ compareJson("containerConfig", MAPPER.valueToTree(desiredContainerConfig),
+ MAPPER.valueToTree(snapshot.containerConfig()), diffs);
+
+ return new DirtyStateResult(!diffs.isEmpty(), diffs);
+ }
+
+ private void compareJson(String prefix, JsonNode desired, JsonNode deployed,
+ List diffs) {
+ if (!(desired instanceof ObjectNode desiredObj) || !(deployed instanceof ObjectNode deployedObj)) {
+ if (!Objects.equals(desired, deployed)) {
+ diffs.add(new DirtyStateResult.Difference(prefix,
+ String.valueOf(desired), String.valueOf(deployed)));
+ }
+ return;
+ }
+ TreeSet keys = new TreeSet<>();
+ desiredObj.fieldNames().forEachRemaining(keys::add);
+ deployedObj.fieldNames().forEachRemaining(keys::add);
+ for (String key : keys) {
+ JsonNode d = desiredObj.get(key);
+ JsonNode p = deployedObj.get(key);
+ if (!Objects.equals(d, p)) {
+ diffs.add(new DirtyStateResult.Difference(prefix + "." + key,
+ String.valueOf(d), String.valueOf(p)));
+ }
+ }
+ }
+}
diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateResult.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateResult.java
new file mode 100644
index 00000000..8af70fed
--- /dev/null
+++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateResult.java
@@ -0,0 +1,7 @@
+package com.cameleer.server.core.runtime;
+
+import java.util.List;
+
+public record DirtyStateResult(boolean dirty, List differences) {
+ public record Difference(String field, String staged, String deployed) {}
+}
diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java
new file mode 100644
index 00000000..ba9ec733
--- /dev/null
+++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java
@@ -0,0 +1,91 @@
+package com.cameleer.server.core.runtime;
+
+import com.cameleer.common.model.ApplicationConfig;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DirtyStateCalculatorTest {
+
+ @Test
+ void noSnapshot_meansEverythingDirty() {
+ DirtyStateCalculator calc = new DirtyStateCalculator();
+
+ ApplicationConfig desiredAgent = new ApplicationConfig();
+ desiredAgent.setSamplingRate(1.0);
+ Map desiredContainer = Map.of("memoryLimitMb", 512);
+
+ DirtyStateResult result = calc.compute(UUID.randomUUID(), desiredAgent, desiredContainer, null);
+
+ assertThat(result.dirty()).isTrue();
+ assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
+ .contains("snapshot");
+ }
+
+ @Test
+ void identicalSnapshot_isClean() {
+ DirtyStateCalculator calc = new DirtyStateCalculator();
+
+ ApplicationConfig cfg = new ApplicationConfig();
+ cfg.setSamplingRate(0.5);
+ Map container = Map.of("memoryLimitMb", 512);
+
+ UUID jarId = UUID.randomUUID();
+ DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, cfg, container);
+ DirtyStateResult result = calc.compute(jarId, cfg, container, snap);
+
+ assertThat(result.dirty()).isFalse();
+ assertThat(result.differences()).isEmpty();
+ }
+
+ @Test
+ void differentJar_marksJarField() {
+ DirtyStateCalculator calc = new DirtyStateCalculator();
+ ApplicationConfig cfg = new ApplicationConfig();
+ Map container = Map.of();
+ UUID v1 = UUID.randomUUID();
+ UUID v2 = UUID.randomUUID();
+ DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(v1, cfg, container);
+
+ DirtyStateResult result = calc.compute(v2, cfg, container, snap);
+
+ assertThat(result.dirty()).isTrue();
+ assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
+ .contains("jarVersionId");
+ }
+
+ @Test
+ void differentSamplingRate_marksAgentField() {
+ DirtyStateCalculator calc = new DirtyStateCalculator();
+
+ ApplicationConfig deployedCfg = new ApplicationConfig();
+ deployedCfg.setSamplingRate(0.5);
+ ApplicationConfig desiredCfg = new ApplicationConfig();
+ desiredCfg.setSamplingRate(1.0);
+ UUID jarId = UUID.randomUUID();
+ DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployedCfg, Map.of());
+
+ DirtyStateResult result = calc.compute(jarId, desiredCfg, Map.of(), snap);
+
+ assertThat(result.dirty()).isTrue();
+ assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
+ .contains("agentConfig.samplingRate");
+ }
+
+ @Test
+ void differentContainerMemory_marksContainerField() {
+ DirtyStateCalculator calc = new DirtyStateCalculator();
+ ApplicationConfig cfg = new ApplicationConfig();
+ UUID jarId = UUID.randomUUID();
+ DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, cfg, Map.of("memoryLimitMb", 512));
+
+ DirtyStateResult result = calc.compute(jarId, cfg, Map.of("memoryLimitMb", 1024), snap);
+
+ assertThat(result.dirty()).isTrue();
+ assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
+ .contains("containerConfig.memoryLimitMb");
+ }
+}