diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java
new file mode 100644
index 00000000..41f0ce31
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java
@@ -0,0 +1,122 @@
+package com.cameleer.server.app.alerting.notify;
+
+import com.cameleer.server.core.alerting.AlertInstance;
+import com.cameleer.server.core.alerting.AlertRule;
+import com.cameleer.server.core.runtime.Environment;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Builds the Mustache template context map from an AlertRule + AlertInstance + Environment.
+ *
+ * Always present: {@code env}, {@code rule}, {@code alert}.
+ * Conditionally present based on {@code rule.conditionKind()}:
+ *
+ * - AGENT_STATE → {@code agent}, {@code app}
+ * - DEPLOYMENT_STATE → {@code deployment}, {@code app}
+ * - ROUTE_METRIC → {@code route}, {@code app}
+ * - EXCHANGE_MATCH → {@code exchange}, {@code app}, {@code route}
+ * - LOG_PATTERN → {@code log}, {@code app}
+ * - JVM_METRIC → {@code metric}, {@code agent}, {@code app}
+ *
+ * Values absent from {@code instance.context()} render as empty string so Mustache templates
+ * remain valid even for env-wide rules that have no app/route scope.
+ */
+@Component
+public class NotificationContextBuilder {
+
+ public Map build(AlertRule rule, AlertInstance instance, Environment env, String uiOrigin) {
+ Map ctx = new LinkedHashMap<>();
+
+ // --- env subtree ---
+ ctx.put("env", Map.of(
+ "slug", env.slug(),
+ "id", env.id().toString()
+ ));
+
+ // --- rule subtree ---
+ ctx.put("rule", Map.of(
+ "id", rule.id().toString(),
+ "name", rule.name(),
+ "severity", rule.severity().name(),
+ "description", rule.description() == null ? "" : rule.description()
+ ));
+
+ // --- alert subtree ---
+ String base = uiOrigin == null ? "" : uiOrigin;
+ ctx.put("alert", Map.of(
+ "id", instance.id().toString(),
+ "state", instance.state().name(),
+ "firedAt", instance.firedAt().toString(),
+ "resolvedAt", instance.resolvedAt() == null ? "" : instance.resolvedAt().toString(),
+ "ackedBy", instance.ackedBy() == null ? "" : instance.ackedBy(),
+ "link", base + "/alerts/inbox/" + instance.id(),
+ "currentValue", instance.currentValue() == null ? "" : instance.currentValue().toString(),
+ "threshold", instance.threshold() == null ? "" : instance.threshold().toString()
+ ));
+
+ // --- per-kind conditional subtrees ---
+ if (rule.conditionKind() != null) {
+ switch (rule.conditionKind()) {
+ case AGENT_STATE -> {
+ ctx.put("agent", subtree(instance, "agent.id", "agent.name", "agent.state"));
+ ctx.put("app", subtree(instance, "app.slug", "app.id"));
+ }
+ case DEPLOYMENT_STATE -> {
+ ctx.put("deployment", subtree(instance, "deployment.id", "deployment.status"));
+ ctx.put("app", subtree(instance, "app.slug", "app.id"));
+ }
+ case ROUTE_METRIC -> {
+ ctx.put("route", subtree(instance, "route.id", "route.uri"));
+ ctx.put("app", subtree(instance, "app.slug", "app.id"));
+ }
+ case EXCHANGE_MATCH -> {
+ ctx.put("exchange", subtree(instance, "exchange.id", "exchange.status"));
+ ctx.put("app", subtree(instance, "app.slug", "app.id"));
+ ctx.put("route", subtree(instance, "route.id", "route.uri"));
+ }
+ case LOG_PATTERN -> {
+ ctx.put("log", subtree(instance, "log.pattern", "log.matchCount"));
+ ctx.put("app", subtree(instance, "app.slug", "app.id"));
+ }
+ case JVM_METRIC -> {
+ ctx.put("metric", subtree(instance, "metric.name", "metric.value"));
+ ctx.put("agent", subtree(instance, "agent.id", "agent.name"));
+ ctx.put("app", subtree(instance, "app.slug", "app.id"));
+ }
+ }
+ }
+
+ return ctx;
+ }
+
+ /**
+ * Extracts a flat subtree from {@code instance.context()} using dotted key paths.
+ * Each path like {@code "agent.id"} becomes the leaf key {@code "id"} in the returned map.
+ * Missing or null values are stored as empty string.
+ */
+ private Map subtree(AlertInstance instance, String... dottedPaths) {
+ Map sub = new LinkedHashMap<>();
+ Map ic = instance.context();
+ for (String path : dottedPaths) {
+ String leafKey = path.contains(".") ? path.substring(path.lastIndexOf('.') + 1) : path;
+ Object val = resolveContext(ic, path);
+ sub.put(leafKey, val == null ? "" : val.toString());
+ }
+ return sub;
+ }
+
+ @SuppressWarnings("unchecked")
+ private Object resolveContext(Map ctx, String path) {
+ if (ctx == null) return null;
+ String[] parts = path.split("\\.");
+ Object current = ctx.get(parts[0]);
+ for (int i = 1; i < parts.length; i++) {
+ if (!(current instanceof Map)) return null;
+ current = ((Map) current).get(parts[i]);
+ }
+ return current;
+ }
+}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java
new file mode 100644
index 00000000..a046922c
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java
@@ -0,0 +1,217 @@
+package com.cameleer.server.app.alerting.notify;
+
+import com.cameleer.server.core.alerting.*;
+import com.cameleer.server.core.runtime.Environment;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class NotificationContextBuilderTest {
+
+ private NotificationContextBuilder builder;
+
+ private static final UUID ENV_ID = UUID.fromString("11111111-1111-1111-1111-111111111111");
+ private static final UUID RULE_ID = UUID.fromString("22222222-2222-2222-2222-222222222222");
+ private static final UUID INST_ID = UUID.fromString("33333333-3333-3333-3333-333333333333");
+
+ @BeforeEach
+ void setUp() {
+ builder = new NotificationContextBuilder();
+ }
+
+ // ---- helpers ----
+
+ private Environment env() {
+ return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, Instant.EPOCH);
+ }
+
+ private AlertRule rule(ConditionKind kind) {
+ AlertCondition condition = switch (kind) {
+ case ROUTE_METRIC -> new RouteMetricCondition(
+ new AlertScope("my-app", "route-1", null),
+ RouteMetric.ERROR_RATE, Comparator.GT, 0.1, 60);
+ case EXCHANGE_MATCH -> new ExchangeMatchCondition(
+ new AlertScope("my-app", "route-1", null),
+ new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
+ FireMode.PER_EXCHANGE, null, null, 30);
+ case AGENT_STATE -> new AgentStateCondition(
+ new AlertScope(null, null, null),
+ "DEAD", 0);
+ case DEPLOYMENT_STATE -> new DeploymentStateCondition(
+ new AlertScope("my-app", null, null),
+ List.of("FAILED"));
+ case LOG_PATTERN -> new LogPatternCondition(
+ new AlertScope("my-app", null, null),
+ "ERROR", "OutOfMemory", 5, 60);
+ case JVM_METRIC -> new JvmMetricCondition(
+ new AlertScope(null, null, "agent-1"),
+ "heap.used", AggregationOp.MAX, Comparator.GT, 90.0, 300);
+ };
+ return new AlertRule(
+ RULE_ID, ENV_ID, "High error rate", "Alert description",
+ AlertSeverity.CRITICAL, true,
+ kind, condition,
+ 60, 120, 30,
+ "{{rule.name}} fired", "Value: {{alert.currentValue}}",
+ List.of(), List.of(),
+ Instant.now(), null, Instant.now(),
+ Map.of(),
+ Instant.now(), "admin", Instant.now(), "admin"
+ );
+ }
+
+ private AlertInstance instance(Map ctx) {
+ return new AlertInstance(
+ INST_ID, RULE_ID, Map.of(), ENV_ID,
+ AlertState.FIRING, AlertSeverity.CRITICAL,
+ Instant.parse("2026-04-19T10:00:00Z"),
+ null, null, null, null,
+ false, 0.95, 0.1,
+ ctx, "Alert fired", "Some message",
+ List.of(), List.of(), List.of()
+ );
+ }
+
+ // ---- env / rule / alert subtrees always present ----
+
+ @Test
+ void envSubtree_alwaysPresent() {
+ var inst = instance(Map.of("route", Map.of("id", "route-1"), "app", Map.of("slug", "my-app")));
+ var ctx = builder.build(rule(ConditionKind.ROUTE_METRIC), inst, env(), null);
+
+ assertThat(ctx).containsKey("env");
+ @SuppressWarnings("unchecked") var env = (Map) ctx.get("env");
+ assertThat(env).containsEntry("slug", "prod")
+ .containsEntry("id", ENV_ID.toString());
+ }
+
+ @Test
+ void ruleSubtree_alwaysPresent() {
+ var inst = instance(Map.of());
+ var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), null);
+
+ @SuppressWarnings("unchecked") var ruleMap = (Map) ctx.get("rule");
+ assertThat(ruleMap).containsEntry("id", RULE_ID.toString())
+ .containsEntry("name", "High error rate")
+ .containsEntry("severity", "CRITICAL")
+ .containsEntry("description", "Alert description");
+ }
+
+ @Test
+ void alertSubtree_alwaysPresent() {
+ var inst = instance(Map.of());
+ var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), "https://ui.example.com");
+
+ @SuppressWarnings("unchecked") var alert = (Map) ctx.get("alert");
+ assertThat(alert).containsEntry("id", INST_ID.toString())
+ .containsEntry("state", "FIRING")
+ .containsEntry("firedAt", "2026-04-19T10:00:00Z")
+ .containsEntry("currentValue", "0.95")
+ .containsEntry("threshold", "0.1");
+ }
+
+ @Test
+ void alertLink_withUiOrigin() {
+ var inst = instance(Map.of());
+ var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), "https://ui.example.com");
+
+ @SuppressWarnings("unchecked") var alert = (Map) ctx.get("alert");
+ assertThat(alert.get("link")).isEqualTo("https://ui.example.com/alerts/inbox/" + INST_ID);
+ }
+
+ @Test
+ void alertLink_withoutUiOrigin_isRelative() {
+ var inst = instance(Map.of());
+ var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), null);
+
+ @SuppressWarnings("unchecked") var alert = (Map) ctx.get("alert");
+ assertThat(alert.get("link")).isEqualTo("/alerts/inbox/" + INST_ID);
+ }
+
+ // ---- conditional subtrees by kind ----
+
+ @Test
+ void exchangeMatch_hasExchangeAppRoute_butNotLogOrMetric() {
+ var ctx = Map.of(
+ "exchange", Map.of("id", "ex-99", "status", "FAILED"),
+ "app", Map.of("slug", "my-app", "id", "app-uuid"),
+ "route", Map.of("id", "route-1", "uri", "direct:start"));
+ var result = builder.build(rule(ConditionKind.EXCHANGE_MATCH), instance(ctx), env(), null);
+
+ assertThat(result).containsKeys("exchange", "app", "route")
+ .doesNotContainKey("log")
+ .doesNotContainKey("metric")
+ .doesNotContainKey("agent");
+ }
+
+ @Test
+ void agentState_hasAgentAndApp_butNotRoute() {
+ var ctx = Map.of(
+ "agent", Map.of("id", "a-42", "name", "my-agent", "state", "DEAD"),
+ "app", Map.of("slug", "my-app", "id", "app-uuid"));
+ var result = builder.build(rule(ConditionKind.AGENT_STATE), instance(ctx), env(), null);
+
+ assertThat(result).containsKeys("agent", "app")
+ .doesNotContainKey("route")
+ .doesNotContainKey("exchange")
+ .doesNotContainKey("log")
+ .doesNotContainKey("metric");
+ }
+
+ @Test
+ void routeMetric_hasRouteAndApp_butNotAgentOrExchange() {
+ var ctx = Map.of(
+ "route", Map.of("id", "route-1", "uri", "timer://tick"),
+ "app", Map.of("slug", "my-app", "id", "app-uuid"));
+ var result = builder.build(rule(ConditionKind.ROUTE_METRIC), instance(ctx), env(), null);
+
+ assertThat(result).containsKeys("route", "app")
+ .doesNotContainKey("agent")
+ .doesNotContainKey("exchange")
+ .doesNotContainKey("log");
+ }
+
+ @Test
+ void logPattern_hasLogAndApp_butNotRouteOrAgent() {
+ var ctx = Map.of(
+ "log", Map.of("pattern", "ERROR", "matchCount", "7"),
+ "app", Map.of("slug", "my-app", "id", "app-uuid"));
+ var result = builder.build(rule(ConditionKind.LOG_PATTERN), instance(ctx), env(), null);
+
+ assertThat(result).containsKeys("log", "app")
+ .doesNotContainKey("route")
+ .doesNotContainKey("agent")
+ .doesNotContainKey("metric");
+ }
+
+ @Test
+ void jvmMetric_hasMetricAgentAndApp() {
+ var ctx = Map.of(
+ "metric", Map.of("name", "heap.used", "value", "88.5"),
+ "agent", Map.of("id", "a-42", "name", "my-agent"),
+ "app", Map.of("slug", "my-app", "id", "app-uuid"));
+ var result = builder.build(rule(ConditionKind.JVM_METRIC), instance(ctx), env(), null);
+
+ assertThat(result).containsKeys("metric", "agent", "app")
+ .doesNotContainKey("route")
+ .doesNotContainKey("exchange")
+ .doesNotContainKey("log");
+ }
+
+ @Test
+ void missingContextValues_emitEmptyString() {
+ // Empty context — subtree values should all be empty string, not null.
+ var inst = instance(Map.of());
+ var result = builder.build(rule(ConditionKind.ROUTE_METRIC), inst, env(), null);
+
+ @SuppressWarnings("unchecked") var route = (Map) result.get("route");
+ assertThat(route.get("id")).isEqualTo("");
+ assertThat(route.get("uri")).isEqualTo("");
+ }
+}