diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RuleTemplateInterpolatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RuleTemplateInterpolatorTests.cs
new file mode 100644
index 00000000..8b1fe488
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/RuleTemplateInterpolatorTests.cs
@@ -0,0 +1,352 @@
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Functions.Services.Notifications;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// A stored RuleResult keeps the rule's {{token}} template. The web and the MCP server resolve
+/// it when they render; a notification has no renderer, so the backend resolves it before the
+/// text leaves. Three things are pinned here: the backend resolution equals the shared cases
+/// every renderer runs, no placeholder reaches a notification for any built-in rule, and the
+/// cut happens after the resolution and on a text boundary.
+///
+public class RuleTemplateInterpolatorTests
+{
+ private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string SessionId = "0f1e2d3c-4b5a-4978-8a6b-5c4d3e2f1a0b";
+ private const string FailedAppName = "Contoso Productivity Suite";
+
+ // ── Shared cases (tests/fixtures/rule-template-interpolation) ─────────
+
+ private static readonly Lazy Fixture = new(() =>
+ {
+ var path = Path.Combine(FindRepoRoot(), "tests", "fixtures", "rule-template-interpolation", "cases.json");
+ // No date parsing: a timestamp-looking string must stay the string the renderers see.
+ return JsonConvert.DeserializeObject(
+ File.ReadAllText(path), new JsonSerializerSettings { DateParseHandling = DateParseHandling.None })!;
+ });
+
+ public static IEnumerable CaseNames()
+ => Fixture.Value["cases"]!.Select(c => new object[] { c.Value("name")! });
+
+ [Theory]
+ [MemberData(nameof(CaseNames))]
+ public void Interpolate_matches_the_shared_case(string name)
+ {
+ var testCase = Fixture.Value["cases"]!.Single(c => c.Value("name") == name);
+ var template = testCase.Value("template");
+ var expected = testCase.Value("expected")!;
+ var evidence = testCase["matchedConditions"] as JObject;
+
+ // Evidence as the rule engine builds it (nested dictionaries) ...
+ Assert.Equal(expected, RuleTemplateInterpolator.Interpolate(template, AsDictionaries(evidence)));
+ // ... and as a JSON read without normalization leaves it (JSON nodes as values).
+ Assert.Equal(expected, RuleTemplateInterpolator.Interpolate(template, AsJsonNodes(evidence)));
+ }
+
+ [Fact]
+ public void Shared_cases_are_present_and_cover_every_auto_field()
+ {
+ Assert.True(Fixture.Value["cases"]!.Count() >= 20, "cases.json lost cases — the parity guard is only as wide as the file.");
+ Assert.Equal(
+ Fixture.Value["autoFields"]!.ToObject()!,
+ RuleEngine.EvidenceAutoFields);
+ }
+
+ // ── Notification variant ──────────────────────────────────────────────
+
+ [Fact]
+ public void Notification_drops_a_list_item_whose_value_was_not_recorded()
+ {
+ var text = "Facts:\n\n- **Reason:** `{{reason}}`\n- **ESP HRESULT:** `{{errorCode}}`\n* **App:** {{appName}}\n1. step {{exitCode}}\n\nDone.";
+ var evidence = new Dictionary
+ {
+ ["enrollment_failed_reason"] = new Dictionary { ["field"] = "reason", ["value"] = "hello_timeout" },
+ };
+
+ var rendered = RuleTemplateInterpolator.InterpolateForNotification(text, evidence);
+
+ Assert.Equal("Facts:\n\n- **Reason:** `hello_timeout`\n\nDone.", rendered);
+ }
+
+ [Fact]
+ public void Notification_replaces_an_unrecorded_value_in_running_text()
+ {
+ var rendered = RuleTemplateInterpolator.InterpolateForNotification(
+ "App **{{appName}}** failed with HRESULT **{{errorCode}}**.",
+ new Dictionary
+ {
+ ["failed_app"] = new Dictionary { ["field"] = "appName", ["value"] = FailedAppName },
+ });
+
+ Assert.Equal($"App **{FailedAppName}** failed with HRESULT **–**.", rendered);
+ }
+
+ [Fact]
+ public void Notification_removes_the_sentence_about_literal_placeholders()
+ {
+ var rendered = RuleTemplateInterpolator.InterpolateForNotification(
+ "See ANALYZE-APP-013 for the root cause. Any field still shown as a literal `{{…}}` placeholder was simply not recorded. Check the timeline.",
+ new Dictionary());
+
+ Assert.Equal("See ANALYZE-APP-013 for the root cause. Check the timeline.", rendered);
+ }
+
+ [Fact]
+ public void Notification_without_evidence_or_text_is_safe()
+ {
+ Assert.Equal(string.Empty, RuleTemplateInterpolator.InterpolateForNotification(null, null));
+ Assert.Equal(string.Empty, RuleTemplateInterpolator.InterpolateForNotification("", new Dictionary()));
+ Assert.Equal("Reason: –", RuleTemplateInterpolator.InterpolateForNotification("Reason: {{reason}}", null));
+ }
+
+ [Fact]
+ public void Notification_collapses_the_gap_a_dropped_paragraph_leaves()
+ {
+ var rendered = RuleTemplateInterpolator.InterpolateForNotification(
+ "First.\n\nOnly a literal `{{…}}` remark.\n\nLast.", new Dictionary());
+
+ Assert.Equal("First.\n\nLast.", rendered);
+ }
+
+ // ── No placeholder leaves the backend ─────────────────────────────────
+
+ [Fact]
+ public void No_builtin_rule_sends_a_placeholder_whatever_the_evidence()
+ {
+ // Worst case for every rule: nothing recorded at all. Both alert builders are the only
+ // paths that put a rule explanation into a notification.
+ var offenders = new List();
+ var templated = 0;
+
+ foreach (var rule in BuiltInAnalyzeRules.GetAll())
+ {
+ if (rule.Explanation.Contains("{{")) templated++;
+
+ var result = new RuleResult
+ {
+ RuleId = rule.RuleId,
+ RuleTitle = rule.Title,
+ Severity = "critical",
+ Category = rule.Category,
+ Explanation = rule.Explanation,
+ };
+
+ var fired = NotificationAlertBuilder.BuildRuleFiredAlert(result, "DESKTOP-TEST01", "SN-0001");
+ var enrollment = NotificationAlertBuilder.BuildEnrollmentAlert(
+ "DESKTOP-TEST01", "SN-0001", null, null, success: false, failureReason: "x", duration: null);
+ NotificationAlertBuilder.AddRuleResultSections(enrollment, new List { result });
+
+ foreach (var section in fired.Sections.Concat(enrollment.Sections))
+ {
+ if (section.Text.Contains("{{") || section.Text.Contains("}}"))
+ offenders.Add($"{rule.RuleId}: {section.Text}");
+ Assert.True(section.Text.Length <= NotificationAlertBuilder.RuleFiredExplanationMaxLength);
+ }
+ }
+
+ // Positive control: the catalog really contains templated rules, so an empty offender
+ // list is a statement about them and not about a catalog without placeholders.
+ Assert.True(templated >= 10, $"Only {templated} built-in rules carry a placeholder — did the catalog load?");
+ Assert.True(offenders.Count == 0, "Placeholder reached a notification:\n" + string.Join("\n", offenders));
+ }
+
+ // ── End to end: engine evidence → alert ───────────────────────────────
+
+ [Fact]
+ public async Task Rule_fired_alert_shows_the_values_the_engine_recorded()
+ {
+ var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
+
+ var alert = NotificationAlertBuilder.BuildRuleFiredAlert(result, "DESKTOP-TEST01", "SN-0001");
+
+ var text = Assert.Single(alert.Sections).Text;
+ Assert.Equal(
+ "The Autopilot enrollment has explicitly failed.\n\n" +
+ "**Key facts extracted from this session's events:**\n\n" +
+ "- **Failure reason:** `esp_terminal_failure`\n" +
+ $"- **First failed app:** `{FailedAppName}`\n" +
+ "- **ESP failed subcategory:** `Apps`\n" +
+ "- **ESP HRESULT:** `0x87d1041c` …",
+ text);
+ // The stored result is untouched: the web and the MCP server still resolve the template.
+ Assert.Contains("{{reason}}", result.Explanation);
+ }
+
+ [Fact]
+ public async Task Rule_fired_alert_reads_evidence_that_went_through_the_table()
+ {
+ var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
+ var fresh = NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections.Single().Text;
+
+ // Same write and read as TableStorageService: JSON column, then nested dictionaries.
+ var json = JsonConvert.SerializeObject(result.MatchedConditions);
+ result.MatchedConditions = AsDictionaries(JObject.Parse(json))!;
+
+ Assert.Equal(fresh, NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections.Single().Text);
+ }
+
+ [Fact]
+ public async Task Rule_fired_alert_omits_the_facts_a_non_esp_failure_never_had()
+ {
+ var result = await RunEnrollmentFailedRuleAsync(withEspFailure: false);
+
+ var text = Assert.Single(NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections).Text;
+
+ Assert.Contains("- **Failure reason:** `hello_timeout`", text);
+ Assert.DoesNotContain("First failed app", text);
+ Assert.DoesNotContain("ESP failed subcategory", text);
+ Assert.DoesNotContain("ESP HRESULT:", text);
+ }
+
+ [Fact]
+ public async Task Enrollment_alert_rule_section_is_resolved_and_cut_on_a_line()
+ {
+ var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
+ var alert = NotificationAlertBuilder.BuildEnrollmentAlert(
+ "DESKTOP-TEST01", "SN-0001", null, null, success: false, failureReason: "x", duration: null);
+
+ NotificationAlertBuilder.AddRuleResultSections(alert, new List { result });
+
+ var text = Assert.Single(alert.Sections).Text;
+ Assert.True(text.Length <= NotificationAlertBuilder.RuleSectionExplanationMaxLength);
+ Assert.EndsWith("- **Failure reason:** `esp_terminal_failure` …", text);
+ }
+
+ // ── Cut ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Truncate_keeps_short_text()
+ => Assert.Equal("short", NotificationAlertBuilder.TruncateAtBoundary("short", 300));
+
+ [Fact]
+ public void Truncate_prefers_a_line_break_then_a_sentence_then_a_word()
+ {
+ var lines = "First paragraph of the text.\n" + new string('a', 40);
+ Assert.Equal("First paragraph of the text. …", NotificationAlertBuilder.TruncateAtBoundary(lines, 50));
+
+ var sentences = "One sentence here. Another sentence that runs on and on without an end";
+ Assert.Equal("One sentence here. …", NotificationAlertBuilder.TruncateAtBoundary(sentences, 30));
+
+ var words = "word word word word word word word word word word";
+ Assert.Equal("word word word word word …", NotificationAlertBuilder.TruncateAtBoundary(words, 30));
+ }
+
+ [Fact]
+ public void Truncate_never_exceeds_the_limit_and_survives_text_without_any_boundary()
+ {
+ var unbroken = new string('x', 500);
+ var cut = NotificationAlertBuilder.TruncateAtBoundary(unbroken, 100);
+
+ Assert.Equal(100, cut.Length);
+ Assert.EndsWith(" …", cut);
+ }
+
+ [Fact]
+ public void Truncate_ignores_a_boundary_that_would_waste_most_of_the_budget()
+ {
+ var text = "Hi.\n" + string.Join(" ", Enumerable.Repeat("word", 60));
+ var cut = NotificationAlertBuilder.TruncateAtBoundary(text, 100);
+
+ Assert.True(cut.Length > 50, cut);
+ Assert.True(cut.Length <= 100);
+ }
+
+ [Fact]
+ public void Truncate_does_not_leave_a_bold_or_code_span_open()
+ {
+ var bold = "See the **App Detection Failure During ESP** rule for the cause and the **remediation steps in detail** below";
+ Assert.Equal("See the **App Detection Failure During ESP** rule for the cause and the remediation steps …",
+ NotificationAlertBuilder.TruncateAtBoundary(bold, 94));
+
+ var code = "The installer returned `1603 fatal error during installation` and stopped the whole chain";
+ Assert.Equal("The installer returned 1603 fatal error during …",
+ NotificationAlertBuilder.TruncateAtBoundary(code, 50));
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────
+
+ private static async Task RunEnrollmentFailedRuleAsync(bool withEspFailure)
+ {
+ var rule = BuiltInAnalyzeRules.GetAll().Single(r => r.RuleId == "ANALYZE-ENRL-001");
+
+ var events = new List
+ {
+ Event("enrollment_failed", 163, new Dictionary
+ {
+ ["reason"] = withEspFailure ? "esp_terminal_failure" : "hello_timeout",
+ }),
+ };
+ if (withEspFailure)
+ {
+ events.Add(Event("esp_failure_settle_started", 161, new Dictionary
+ {
+ ["failedSubcategory"] = "Apps",
+ ["errorCode"] = "0x87d1041c",
+ }));
+ events.Add(Event("app_install_failed", 158, new Dictionary
+ {
+ ["appId"] = "00000000-0000-4000-8000-000000000001",
+ ["appName"] = FailedAppName,
+ ["errorCode"] = "0x87d1041c",
+ }));
+ }
+
+ var ruleRepo = new Mock();
+ ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List { rule });
+ ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List());
+ ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny())).ReturnsAsync(new Dictionary());
+ ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List());
+
+ var sessionRepo = new Mock();
+ sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny())).ReturnsAsync(events);
+
+ var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger.Instance);
+ var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger.Instance);
+
+ var outcome = await engine.AnalyzeSessionAsync(TenantId, SessionId);
+ return Assert.Single(outcome.Results);
+ }
+
+ private static EnrollmentEvent Event(string eventType, long sequence, Dictionary data) => new()
+ {
+ EventId = Guid.NewGuid().ToString(),
+ TenantId = TenantId,
+ SessionId = SessionId,
+ EventType = eventType,
+ Timestamp = DateTime.UtcNow,
+ Sequence = sequence,
+ Data = data,
+ };
+
+ private static Dictionary? AsJsonNodes(JObject? evidence)
+ => evidence?.Properties().ToDictionary(p => p.Name, p => (object)p.Value);
+
+ private static Dictionary? AsDictionaries(JObject? evidence)
+ => evidence?.Properties().ToDictionary(p => p.Name, p => Plain(p.Value)!);
+
+ private static object? Plain(JToken token) => token switch
+ {
+ JObject o => o.Properties().ToDictionary(p => p.Name, p => Plain(p.Value)!),
+ JArray a => a.Select(Plain).ToList(),
+ JValue v => v.Value,
+ _ => null,
+ };
+
+ private static string FindRepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
+ {
+ dir = dir.Parent;
+ }
+ Assert.NotNull(dir);
+ return dir!.FullName;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs
index c4d638a3..8f0c9100 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs
@@ -233,13 +233,9 @@ namespace AutopilotMonitor.Functions.Services.Notifications
},
};
- if (!string.IsNullOrEmpty(result.Explanation))
- {
- var explanation = result.Explanation.Length > 300
- ? result.Explanation[..300] + "..."
- : result.Explanation;
+ var explanation = RenderExplanation(result, RuleFiredExplanationMaxLength);
+ if (explanation.Length > 0)
alert.Sections.Add(new NotificationSection { Title = "Explanation", Text = explanation });
- }
if (!string.IsNullOrEmpty(sessionUrl))
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "Open session", Url = sessionUrl });
@@ -273,18 +269,77 @@ namespace AutopilotMonitor.Functions.Services.Notifications
_ => "\ud83d\udfe1"
};
- var explanation = r.Explanation?.Length > 200
- ? r.Explanation[..200] + "..."
- : r.Explanation ?? "";
-
alert.Sections.Add(new NotificationSection
{
Title = $"{emoji} {r.RuleTitle}",
- Text = explanation
+ Text = RenderExplanation(r, RuleSectionExplanationMaxLength)
});
}
}
+ internal const int RuleFiredExplanationMaxLength = 300;
+ internal const int RuleSectionExplanationMaxLength = 200;
+
+ ///
+ /// A stored is the rule's template. Tokens are
+ /// resolved BEFORE the cut: cutting first would split a placeholder or spend the
+ /// budget on placeholder names instead of the values.
+ ///
+ private static string RenderExplanation(RuleResult result, int maxLength)
+ => TruncateAtBoundary(
+ RuleTemplateInterpolator.InterpolateForNotification(result.Explanation, result.MatchedConditions),
+ maxLength);
+
+ ///
+ /// Shortens to at most characters at the last line break
+ /// inside the limit, else the last sentence end, else the last word. A boundary in the
+ /// first half of the limit is not taken, so the cut never throws away most of the budget.
+ /// A bold or code span the cut left open is closed by dropping its opening marker.
+ ///
+ internal static string TruncateAtBoundary(string text, int maxLength)
+ {
+ const string marker = " …";
+ if (text.Length <= maxLength)
+ return text;
+
+ var window = text[..(maxLength - marker.Length)];
+ var floor = window.Length / 2;
+
+ var cut = window.LastIndexOf('\n');
+ if (cut < floor)
+ {
+ cut = -1;
+ for (var i = window.Length - 2; i >= floor; i--)
+ {
+ if (window[i] is '.' or '!' or '?' && char.IsWhiteSpace(window[i + 1]))
+ {
+ cut = i + 1;
+ break;
+ }
+ }
+ }
+ if (cut < floor)
+ cut = window.LastIndexOf(' ');
+ if (cut < floor)
+ cut = window.Length;
+
+ var kept = DropUnpairedMarker(DropUnpairedMarker(window[..cut], "**"), "`");
+ return kept.TrimEnd() + marker;
+ }
+
+ private static string DropUnpairedMarker(string text, string markdownMarker)
+ {
+ var count = 0;
+ var last = -1;
+ for (var i = text.IndexOf(markdownMarker, StringComparison.Ordinal); i >= 0;
+ i = text.IndexOf(markdownMarker, i + markdownMarker.Length, StringComparison.Ordinal))
+ {
+ count++;
+ last = i;
+ }
+ return count % 2 == 0 ? text : text.Remove(last, markdownMarker.Length);
+ }
+
///
/// Builds a notification alert for an SLA breach. Dispatches on
/// : SuccessRate , Duration , or AppInstall .
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs
index dcdb0fa5..1a7fabe7 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs
@@ -1307,6 +1307,17 @@ namespace AutopilotMonitor.Functions.Services
return (true, primary);
}
+ ///
+ /// Same-event fields copied into every evidence entry. Whitelist: short identifiers only;
+ /// errorDetail/message are excluded (can be multi-KB). Every renderer resolves these as
+ /// {{token}} — the web, the MCP server and
+ /// carry the same list, pinned by tests/fixtures/rule-template-interpolation/cases.json.
+ ///
+ internal static readonly IReadOnlyList EvidenceAutoFields = new[]
+ {
+ "appId", "appName", "errorPatternId", "errorCode", "exitCode", "status", "timeoutHours"
+ };
+
///
/// Adds slim identifying data fields from an event to the evidence dictionary with a prefix.
/// Free-text fields like errorDetail (often a stack trace) and message are
@@ -1317,10 +1328,7 @@ namespace AutopilotMonitor.Functions.Services
{
if (evt.Data == null) return;
- // Whitelist: short identifiers only. errorDetail/message removed (can be multi-KB).
- var knownFields = new[] { "appId", "appName", "errorPatternId", "errorCode", "exitCode", "status", "timeoutHours" };
-
- foreach (var field in knownFields)
+ foreach (var field in EvidenceAutoFields)
{
var val = GetDataFieldValue(evt, field);
if (val != null)
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleTemplateInterpolator.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleTemplateInterpolator.cs
new file mode 100644
index 00000000..a7ac5531
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleTemplateInterpolator.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.RegularExpressions;
+using AutopilotMonitor.Shared.Models;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace AutopilotMonitor.Functions.Services
+{
+ ///
+ /// Resolves the {{token}} placeholders of a rule's explanation/remediation text from a
+ /// result's . A stored keeps
+ /// the rule's raw template; the web (lib/interpolateRuleTemplate.ts ) and the MCP server
+ /// (src/interpolate-rule-template.ts ) resolve it when they render. This is the backend
+ /// copy for text that leaves without passing either of them (notifications).
+ ///
+ /// Resolution order, identical in all three and pinned by
+ /// tests/fixtures/rule-template-interpolation/cases.json :
+ ///
+ ///
+ /// - an evidence entry whose
field equals the token (the last such entry wins);
+ /// - a same-event auto-field named like the token (
;
+ /// the first non-empty one wins, which pins it to the rule's earliest matched condition);
+ /// - an evidence entry whose signal name equals the token;
+ /// - otherwise the token stays as written.
+ ///
+ ///
+ public static class RuleTemplateInterpolator
+ {
+ /// Shown in a notification where running text names a value that was not recorded.
+ internal const string NotRecorded = "–";
+
+ private static readonly Regex TokenRegex = new Regex(
+ @"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ private static readonly Regex ListItemRegex = new Regex(
+ @"^\s*(?:[-*+]|\d+[.)])\s+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ private static readonly Regex SentenceBreakRegex = new Regex(
+ @"(?<=[.!?])\s+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ private static readonly Regex BlankRunRegex = new Regex(
+ @"\n{3,}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ ///
+ /// Replaces every resolvable token; an unresolved one stays literal, exactly as the web
+ /// and the MCP server render it.
+ ///
+ public static string Interpolate(string? text, IReadOnlyDictionary? matchedConditions)
+ {
+ if (string.IsNullOrEmpty(text))
+ return string.Empty;
+
+ var lookup = Lookup.From(matchedConditions);
+ return TokenRegex.Replace(text, m => lookup.Resolve(m.Groups[1].Value) ?? m.Value);
+ }
+
+ ///
+ /// Interpolation for text that leaves the backend as a notification. The recipient has no
+ /// renderer and no footnote explaining a literal placeholder, so none may survive: a list
+ /// item whose token was not recorded is dropped, a token in running text becomes
+ /// , and a sentence that talks about literal placeholders is removed.
+ ///
+ public static string InterpolateForNotification(string? text, IReadOnlyDictionary? matchedConditions)
+ {
+ if (string.IsNullOrEmpty(text))
+ return string.Empty;
+
+ var lookup = Lookup.From(matchedConditions);
+ var lines = new List();
+
+ foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
+ {
+ var unresolved = false;
+ var rendered = TokenRegex.Replace(line, m =>
+ {
+ var value = lookup.Resolve(m.Groups[1].Value);
+ if (value != null)
+ return value;
+ unresolved = true;
+ return NotRecorded;
+ });
+
+ if (unresolved && ListItemRegex.IsMatch(line))
+ continue;
+
+ if (rendered.Contains("{{", StringComparison.Ordinal))
+ {
+ rendered = string.Join(" ", SentenceBreakRegex.Split(rendered)
+ .Where(sentence => !sentence.Contains("{{", StringComparison.Ordinal)));
+ if (rendered.Length == 0)
+ continue;
+ }
+
+ lines.Add(rendered);
+ }
+
+ return BlankRunRegex.Replace(string.Join("\n", lines), "\n\n").Trim();
+ }
+
+ private sealed class Lookup
+ {
+ private readonly Dictionary _byField = new Dictionary(StringComparer.Ordinal);
+ private readonly Dictionary _byAutoField = new Dictionary(StringComparer.Ordinal);
+ private readonly Dictionary _bySignal = new Dictionary(StringComparer.Ordinal);
+
+ public string? Resolve(string token)
+ {
+ if (_byField.TryGetValue(token, out var value)) return value;
+ if (_byAutoField.TryGetValue(token, out value)) return value;
+ if (_bySignal.TryGetValue(token, out value)) return value;
+ return null;
+ }
+
+ public static Lookup From(IReadOnlyDictionary? matchedConditions)
+ {
+ var lookup = new Lookup();
+ if (matchedConditions == null || matchedConditions.Count == 0)
+ return lookup;
+
+ // Evidence arrives in two shapes: nested dictionaries straight from the rule engine,
+ // or whatever the table read produced. Going through JSON gives both the form the
+ // web and the MCP server see. JsonSerializer.Create() ignores the process-wide
+ // default settings, so a naming strategy configured there cannot rename the keys.
+ JObject root;
+ try
+ {
+ root = JObject.FromObject(matchedConditions, JsonSerializer.Create());
+ }
+ catch (Exception)
+ {
+ // Evidence that cannot be serialized resolves nothing; the text is still sent.
+ return lookup;
+ }
+
+ foreach (var property in root.Properties())
+ {
+ if (property.Value is not JObject evidence)
+ {
+ if (property.Value is JValue scalar && scalar.Type != JTokenType.Null && scalar.Type != JTokenType.Undefined)
+ lookup._bySignal[property.Name] = Format(scalar);
+ continue;
+ }
+
+ var hasValue = evidence.TryGetValue("value", out var valueToken);
+ if (hasValue)
+ {
+ if (evidence["field"] is JValue { Type: JTokenType.String } field)
+ lookup._byField[(string)field.Value!] = Format(valueToken);
+ lookup._bySignal[property.Name] = Format(valueToken);
+ }
+
+ foreach (var autoField in RuleEngine.EvidenceAutoFields)
+ {
+ if (lookup._byAutoField.ContainsKey(autoField))
+ continue;
+ var formatted = Format(evidence[autoField]);
+ if (formatted.Length > 0)
+ lookup._byAutoField[autoField] = formatted;
+ }
+ }
+
+ return lookup;
+ }
+
+ // Mirrors the TypeScript formatValue: strings as they are, numbers and booleans the
+ // way JavaScript prints them, anything structured as compact JSON.
+ private static string Format(JToken? token)
+ {
+ if (token == null)
+ return string.Empty;
+
+ switch (token.Type)
+ {
+ case JTokenType.Null:
+ case JTokenType.Undefined:
+ return string.Empty;
+ case JTokenType.String:
+ return (string)((JValue)token).Value!;
+ case JTokenType.Boolean:
+ return (bool)token ? "true" : "false";
+ case JTokenType.Integer:
+ return Convert.ToString(((JValue)token).Value, CultureInfo.InvariantCulture) ?? string.Empty;
+ case JTokenType.Float:
+ return Convert.ToDouble(((JValue)token).Value, CultureInfo.InvariantCulture)
+ .ToString(CultureInfo.InvariantCulture);
+ case JTokenType.Object:
+ case JTokenType.Array:
+ return token.ToString(Formatting.None);
+ default:
+ // Date, Guid, TimeSpan, Uri: their JSON string form without the quotes.
+ return token.ToString(Formatting.None).Trim('"');
+ }
+ }
+ }
+ }
+}
diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/interpolate-rule-template.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/interpolate-rule-template.test.ts
index 4010a466..68c09e55 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/interpolate-rule-template.test.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/interpolate-rule-template.test.ts
@@ -1,5 +1,45 @@
import { describe, it, expect } from 'vitest';
-import { interpolateRuleTemplate, interpolateAnalysisResults } from '../interpolate-rule-template.js';
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ interpolateRuleTemplate,
+ interpolateAnalysisResults,
+ RULE_TEMPLATE_AUTO_FIELDS,
+} from '../interpolate-rule-template.js';
+
+interface SharedCase {
+ name: string;
+ template: string | null;
+ matchedConditions: Record | null;
+ expected: string;
+}
+
+/** tests/fixtures/rule-template-interpolation at the repo root — the cases every renderer runs. */
+function loadSharedCases(): { autoFields: string[]; cases: SharedCase[] } {
+ let dir = dirname(fileURLToPath(import.meta.url));
+ while (!existsSync(join(dir, 'AutopilotMonitor.sln'))) {
+ const parent = dirname(dir);
+ if (parent === dir) throw new Error('repository root (AutopilotMonitor.sln) not found');
+ dir = parent;
+ }
+ return JSON.parse(
+ readFileSync(join(dir, 'tests', 'fixtures', 'rule-template-interpolation', 'cases.json'), 'utf8'),
+ );
+}
+
+describe('interpolateRuleTemplate — shared cases (web = MCP = backend)', () => {
+ const shared = loadSharedCases();
+
+ it('has cases and the same auto-field list as the rule engine', () => {
+ expect(shared.cases.length).toBeGreaterThanOrEqual(20);
+ expect([...RULE_TEMPLATE_AUTO_FIELDS]).toEqual(shared.autoFields);
+ });
+
+ it.each(shared.cases.map((c) => [c.name, c] as const))('%s', (_name, c) => {
+ expect(interpolateRuleTemplate(c.template, c.matchedConditions)).toBe(c.expected);
+ });
+});
describe('interpolateRuleTemplate', () => {
// Shape of a real ANALYZE-ENRL-001 matchedConditions map (the case from the
diff --git a/src/McpServer/autopilot-monitor-mcp/src/interpolate-rule-template.ts b/src/McpServer/autopilot-monitor-mcp/src/interpolate-rule-template.ts
index 9a3ed4ef..eb3d2019 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/interpolate-rule-template.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/interpolate-rule-template.ts
@@ -8,15 +8,33 @@
* Keep the resolution order in lock-step with the web copy:
* 1. matchedConditions entry whose `field` equals `token`.
* 2. matchedConditions entry carrying a whitelisted same-event auto-field
- * named `token` (backend `AddDataFieldsToEvidence`: appId, appName,
- * errorPatternId, errorCode, exitCode, status). First non-empty wins so the
+ * named `token` (`RULE_TEMPLATE_AUTO_FIELDS`). First non-empty wins so the
* value pins to the rule's required-condition event.
* 3. matchedConditions entry whose key (signal name) equals `token`.
* 4. Unresolved tokens are left untouched so authors notice the typo (and the
* reworded ANALYZE-ENRL-001 footnote explains a genuinely-absent field).
*
* Token chars: [a-zA-Z0-9_]. Whitespace inside the braces is tolerated.
+ *
+ * Web, MCP and the backend (notifications) all run the cases in
+ * `tests/fixtures/rule-template-interpolation/cases.json` at the repo root.
*/
+
+/**
+ * Mirror of the backend evidence auto-capture whitelist (`RuleEngine.EvidenceAutoFields`).
+ * Explicit list keeps a rogue evidence key (e.g. accidental `description`) from shadowing
+ * a token. Also the list validate_rule lints tokens against.
+ */
+export const RULE_TEMPLATE_AUTO_FIELDS = [
+ 'appId',
+ 'appName',
+ 'errorPatternId',
+ 'errorCode',
+ 'exitCode',
+ 'status',
+ 'timeoutHours',
+] as const;
+
export function interpolateRuleTemplate(
text: string | null | undefined,
matchedConditions: Record | null | undefined
@@ -28,10 +46,6 @@ export function interpolateRuleTemplate(
const byAutoField = new Map();
const bySignal = new Map();
- // Mirror of the backend AddDataFieldsToEvidence whitelist. Explicit list keeps a
- // rogue evidence key (e.g. accidental `description`) from shadowing a token.
- const AUTO_FIELDS = ['appId', 'appName', 'errorPatternId', 'errorCode', 'exitCode', 'status'];
-
for (const [signal, evidence] of Object.entries(matchedConditions)) {
if (!evidence || typeof evidence !== 'object') {
if (evidence != null) bySignal.set(signal, formatValue(evidence));
@@ -44,7 +58,7 @@ export function interpolateRuleTemplate(
if ('value' in e) {
bySignal.set(signal, formatValue(e.value));
}
- for (const af of AUTO_FIELDS) {
+ for (const af of RULE_TEMPLATE_AUTO_FIELDS) {
if (byAutoField.has(af)) continue;
const v = e[af];
if (v != null && v !== '') byAutoField.set(af, formatValue(v));
diff --git a/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts b/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts
index d2394ccd..31f3fb4b 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts
@@ -250,7 +250,7 @@ export const RULE_AUTHORING_GUIDE = {
'explanation and remediation text may contain {{token}} placeholders, resolved from the ' +
'matched evidence at display time: first a matched condition whose dataField equals the ' +
'token, then the auto-captured fields appId / appName / errorPatternId / errorCode / ' +
- 'exitCode / status from the matched events, then a condition SIGNAL name. Unresolved tokens ' +
+ 'exitCode / status / timeoutHours from the matched events, then a condition SIGNAL name. Unresolved tokens ' +
'stay visible as {{token}} so typos are noticeable. test_analyze_rule returns the ' +
'interpolated preview for a real session.',
markSessionAsFailed:
diff --git a/src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts b/src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts
index 46b6e5f7..0d3cd87d 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts
@@ -27,6 +27,7 @@ import addFormatsModule from 'ajv-formats';
const addFormats = addFormatsModule as unknown as (ajv: Ajv2020) => void;
import { GATHER_RULE_SCHEMA, ANALYZE_RULE_SCHEMA, RULE_GUARDRAILS } from './rule-authoring.generated.js';
import { isKnownEventType } from './resource-catalog.js';
+import { RULE_TEMPLATE_AUTO_FIELDS } from './interpolate-rule-template.js';
export type FindingLevel = 'error' | 'warning' | 'info';
@@ -274,9 +275,8 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
// ── Analyze-rule semantic lint ──────────────────────────────────────────────
-/** Mirrors the backend evidence auto-capture whitelist (AddDataFieldsToEvidence) and
- * the interpolation resolution order (interpolate-rule-template.ts). */
-const AUTO_FIELDS = ['appId', 'appName', 'errorPatternId', 'errorCode', 'exitCode', 'status'];
+/** The auto-captured fields a token may resolve to — the interpolator's own list. */
+const AUTO_FIELDS = RULE_TEMPLATE_AUTO_FIELDS;
/** The only condition strings EvaluateConfidenceFactor parses — anything else is
* silently false in production. Exact spacing matters. */
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/interpolateRuleTemplate.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/interpolateRuleTemplate.test.ts
index 2ec018fc..b29a3aa6 100644
--- a/src/Web/autopilot-monitor-web/lib/__tests__/interpolateRuleTemplate.test.ts
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/interpolateRuleTemplate.test.ts
@@ -1,5 +1,40 @@
import { describe, it, expect } from "vitest";
-import { interpolateRuleTemplate } from "../interpolateRuleTemplate";
+import fs from "node:fs";
+import path from "node:path";
+import { interpolateRuleTemplate, RULE_TEMPLATE_AUTO_FIELDS } from "../interpolateRuleTemplate";
+
+interface SharedCase {
+ name: string;
+ template: string | null;
+ matchedConditions: Record | null;
+ expected: string;
+}
+
+/** tests/fixtures/rule-template-interpolation at the repo root — the cases every renderer runs. */
+function loadSharedCases(): { autoFields: string[]; cases: SharedCase[] } {
+ let dir = __dirname;
+ while (!fs.existsSync(path.join(dir, "AutopilotMonitor.sln"))) {
+ const parent = path.dirname(dir);
+ if (parent === dir) throw new Error("repository root (AutopilotMonitor.sln) not found");
+ dir = parent;
+ }
+ return JSON.parse(
+ fs.readFileSync(path.join(dir, "tests", "fixtures", "rule-template-interpolation", "cases.json"), "utf8")
+ );
+}
+
+describe("interpolateRuleTemplate — shared cases (web = MCP = backend)", () => {
+ const shared = loadSharedCases();
+
+ it("has cases and the same auto-field list as the rule engine", () => {
+ expect(shared.cases.length).toBeGreaterThanOrEqual(20);
+ expect([...RULE_TEMPLATE_AUTO_FIELDS]).toEqual(shared.autoFields);
+ });
+
+ it.each(shared.cases.map((c) => [c.name, c] as const))("%s", (_name, c) => {
+ expect(interpolateRuleTemplate(c.template, c.matchedConditions)).toBe(c.expected);
+ });
+});
describe("interpolateRuleTemplate", () => {
const matched = {
diff --git a/src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts b/src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts
index e274dcf9..dfaae0ad 100644
--- a/src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts
+++ b/src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts
@@ -3,13 +3,13 @@
* with values pulled from the rule's `matchedConditions` evidence map.
*
* Resolution order for `{{token}}`:
- * 1. The first matchedConditions entry whose `field` equals `token`
- * (e.g. {{uefiCA2023Status}} -> entry where field === "uefiCA2023Status").
+ * 1. A matchedConditions entry whose `field` equals `token`
+ * (e.g. {{uefiCA2023Status}} -> entry where field === "uefiCA2023Status");
+ * when several entries name the same field, the last one wins.
* 2. The first matchedConditions entry that carries a same-event auto-field
* named `token` (added by the backend's
* `RuleEngine.ConditionEvaluators.AddDataFieldsToEvidence` whitelist —
- * currently `appId`, `appName`, `errorPatternId`, `errorCode`, `exitCode`,
- * `status`). Using "first" pins these to the same source event as the
+ * `RULE_TEMPLATE_AUTO_FIELDS`). Using "first" pins these to the same source event as the
* rule's required condition, avoiding cross-event interpolation drift
* when a session has multiple `app_install_failed` events.
* 3. A matchedConditions entry whose key (signal name) equals `token`
@@ -17,7 +17,27 @@
* 4. Unresolved tokens are left untouched so authors notice the typo.
*
* Token chars: [a-zA-Z0-9_]. Whitespace inside the braces is tolerated.
+ *
+ * The MCP server and the backend (notifications) carry the same resolution; all three run
+ * the cases in `tests/fixtures/rule-template-interpolation/cases.json` at the repo root.
*/
+
+/**
+ * Whitelist of auto-injected event fields the backend rule engine adds to every
+ * matched-condition evidence dict (`RuleEngine.EvidenceAutoFields`). Kept explicit so a
+ * rogue evidence key (e.g. accidental `description`) can't shadow a template token from
+ * another resolution path.
+ */
+export const RULE_TEMPLATE_AUTO_FIELDS = [
+ "appId",
+ "appName",
+ "errorPatternId",
+ "errorCode",
+ "exitCode",
+ "status",
+ "timeoutHours",
+] as const;
+
export function interpolateRuleTemplate(
text: string | null | undefined,
matchedConditions: Record | null | undefined
@@ -29,12 +49,6 @@ export function interpolateRuleTemplate(
const byAutoField = new Map();
const bySignal = new Map();
- // Whitelist of auto-injected event fields the backend rule engine adds to every
- // matched-condition evidence dict via AddDataFieldsToEvidence. Mirror keeps the
- // mapping explicit so a rogue evidence key (e.g. accidental `description`) can't
- // shadow a template token from another resolution path.
- const AUTO_FIELDS = ["appId", "appName", "errorPatternId", "errorCode", "exitCode", "status"];
-
for (const [signal, evidence] of Object.entries(matchedConditions)) {
if (!evidence || typeof evidence !== "object") {
if (evidence != null) bySignal.set(signal, formatValue(evidence));
@@ -50,7 +64,7 @@ export function interpolateRuleTemplate(
// Auto-field fallback: first non-empty wins (insertion order of
// matchedConditions mirrors rule.conditions order, so the required
// condition's event is checked first).
- for (const af of AUTO_FIELDS) {
+ for (const af of RULE_TEMPLATE_AUTO_FIELDS) {
if (byAutoField.has(af)) continue;
const v = e[af];
if (v != null && v !== "") byAutoField.set(af, formatValue(v));
diff --git a/tests/fixtures/rule-template-interpolation/README.md b/tests/fixtures/rule-template-interpolation/README.md
new file mode 100644
index 00000000..48fc3b6c
--- /dev/null
+++ b/tests/fixtures/rule-template-interpolation/README.md
@@ -0,0 +1,19 @@
+# Rule template interpolation — shared cases
+
+A rule result stores the rule's text with its `{{token}}` placeholders; each renderer resolves
+them from the result's `matchedConditions`. `cases.json` is the one definition of that
+resolution, and every implementation runs all of it:
+
+- Web — `interpolateRuleTemplate` in `src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts`
+ (test: `lib/__tests__/interpolateRuleTemplate.test.ts`)
+- MCP server — `interpolateRuleTemplate` in `src/McpServer/autopilot-monitor-mcp/src/interpolate-rule-template.ts`
+ (test: `src/__tests__/interpolate-rule-template.test.ts`)
+- Backend — `RuleTemplateInterpolator.Interpolate` in
+ `src/Backend/AutopilotMonitor.Functions/Services/RuleTemplateInterpolator.cs`
+ (test: `RuleTemplateInterpolatorTests.cs`)
+
+`autoFields` is the list of same-event fields the rule engine copies into every evidence
+entry (`RuleEngine.EvidenceAutoFields`). Each implementation's list is compared against it.
+
+A change to the resolution goes into `cases.json` first; the three tests then say which
+implementation is behind.
diff --git a/tests/fixtures/rule-template-interpolation/cases.json b/tests/fixtures/rule-template-interpolation/cases.json
new file mode 100644
index 00000000..ad2094ff
--- /dev/null
+++ b/tests/fixtures/rule-template-interpolation/cases.json
@@ -0,0 +1,181 @@
+{
+ "autoFields": ["appId", "appName", "errorPatternId", "errorCode", "exitCode", "status", "timeoutHours"],
+ "cases": [
+ {
+ "name": "token resolves from the entry whose field equals it",
+ "template": "Status: `{{uefiCA2023Status}}`",
+ "matchedConditions": {
+ "ca2023_not_updated": { "eventId": "evt-1", "sequence": 23, "timestamp": "2026-04-28T18:24:16Z", "eventType": "secureboot_status", "field": "uefiCA2023Status", "value": "NotStarted" }
+ },
+ "expected": "Status: `NotStarted`"
+ },
+ {
+ "name": "several tokens in one text, engine evidence shape",
+ "template": "- **Failure reason:** `{{reason}}`\n- **First failed app:** `{{appName}}`\n- **ESP failed subcategory:** `{{failedSubcategory}}`\n- **ESP HRESULT:** `{{errorCode}}`",
+ "matchedConditions": {
+ "enrollment_failed": { "eventId": "evt-9", "sequence": 431, "timestamp": "2026-05-28T10:41:16Z", "eventType": "enrollment_failed", "count": 1, "errorCode": "0x87d1041c" },
+ "enrollment_failed_reason": { "eventId": "evt-9", "sequence": 431, "timestamp": "2026-05-28T10:41:16Z", "eventType": "enrollment_failed", "field": "reason", "value": "esp_terminal_failure", "errorCode": "0x87d1041c" },
+ "esp_failure_subcategory": { "eventId": "evt-8", "sequence": 429, "timestamp": "2026-05-28T10:41:12Z", "eventType": "esp_failure_settle_started", "field": "failedSubcategory", "value": "Apps", "errorCode": "0x87d1041c" },
+ "esp_failure_errorcode": { "eventId": "evt-8", "sequence": 429, "timestamp": "2026-05-28T10:41:12Z", "eventType": "esp_failure_settle_started", "field": "errorCode", "value": "0x87d1041c", "errorCode": "0x87d1041c" },
+ "failed_app": { "eventId": "evt-7", "sequence": 420, "timestamp": "2026-05-28T10:41:06Z", "eventType": "app_install_failed", "field": "appName", "value": "Contoso Productivity Suite", "appId": "00000000-0000-4000-8000-000000000001", "appName": "Contoso Productivity Suite", "errorCode": "0x87d1041c" },
+ "factor_failed_app": true
+ },
+ "expected": "- **Failure reason:** `esp_terminal_failure`\n- **First failed app:** `Contoso Productivity Suite`\n- **ESP failed subcategory:** `Apps`\n- **ESP HRESULT:** `0x87d1041c`"
+ },
+ {
+ "name": "token without evidence stays literal",
+ "template": "sub: `{{failedSubcategory}}` reason: {{reason}}",
+ "matchedConditions": {
+ "enrollment_failed_reason": { "field": "reason", "value": "hello_timeout" }
+ },
+ "expected": "sub: `{{failedSubcategory}}` reason: hello_timeout"
+ },
+ {
+ "name": "whitespace inside the braces is tolerated",
+ "template": "[{{ appName }}]",
+ "matchedConditions": { "failed_app": { "field": "appName", "value": "Contoso VPN" } },
+ "expected": "[Contoso VPN]"
+ },
+ {
+ "name": "no evidence map leaves the text unchanged",
+ "template": "hello {{reason}}",
+ "matchedConditions": null,
+ "expected": "hello {{reason}}"
+ },
+ {
+ "name": "empty text",
+ "template": "",
+ "matchedConditions": { "sig": { "field": "reason", "value": "x" } },
+ "expected": ""
+ },
+ {
+ "name": "missing text yields an empty string",
+ "template": null,
+ "matchedConditions": { "sig": { "field": "reason", "value": "x" } },
+ "expected": ""
+ },
+ {
+ "name": "text without placeholders",
+ "template": "plain text with {single} braces",
+ "matchedConditions": { "sig": { "field": "reason", "value": "x" } },
+ "expected": "plain text with {single} braces"
+ },
+ {
+ "name": "numbers and booleans render without type decoration",
+ "template": "seq={{n}} ok={{b}} ratio={{r}} whole={{w}} on={{t}}",
+ "matchedConditions": {
+ "a": { "field": "n", "value": 42 },
+ "b": { "field": "b", "value": false },
+ "c": { "field": "r", "value": 1.5 },
+ "d": { "field": "w", "value": 120.0 },
+ "e": { "field": "t", "value": true }
+ },
+ "expected": "seq=42 ok=false ratio=1.5 whole=120 on=true"
+ },
+ {
+ "name": "signal name resolves when no field matches",
+ "template": "{{ca2023_not_updated}}",
+ "matchedConditions": {
+ "ca2023_not_updated": { "field": "uefiCA2023Status", "value": "NotStarted" }
+ },
+ "expected": "NotStarted"
+ },
+ {
+ "name": "scalar evidence resolves by signal name",
+ "template": "{{factor_failed_app}} {{note}} {{hits}}",
+ "matchedConditions": { "factor_failed_app": true, "note": "plain", "hits": 3 },
+ "expected": "true plain 3"
+ },
+ {
+ "name": "auto-field comes from the first entry that carries it",
+ "template": "App={{appName}} HR={{errorCode}}",
+ "matchedConditions": {
+ "esp_apps_detection_failure": { "field": "failureType", "value": "esp_apps_detection_failure", "appName": "Contoso Productivity Suite", "errorCode": "0x87d1041c" },
+ "another_app_failure": { "field": "isError", "value": "true", "appName": "Some Other App", "errorCode": "0x80070005" }
+ },
+ "expected": "App=Contoso Productivity Suite HR=0x87d1041c"
+ },
+ {
+ "name": "empty auto-field is skipped for a later entry",
+ "template": "{{exitCode}}/{{status}}",
+ "matchedConditions": {
+ "first": { "field": "x", "value": "1", "exitCode": "", "status": null },
+ "second": { "field": "y", "value": "2", "exitCode": "1603", "status": "Failed" }
+ },
+ "expected": "1603/Failed"
+ },
+ {
+ "name": "every auto-field is resolvable",
+ "template": "{{appId}}|{{appName}}|{{errorPatternId}}|{{errorCode}}|{{exitCode}}|{{status}}|{{timeoutHours}}",
+ "matchedConditions": {
+ "sig": { "field": "other", "value": "v", "appId": "id-1", "appName": "Contoso VPN", "errorPatternId": "pattern-1", "errorCode": "0x1", "exitCode": "2", "status": "Failed", "timeoutHours": "5" }
+ },
+ "expected": "id-1|Contoso VPN|pattern-1|0x1|2|Failed|5"
+ },
+ {
+ "name": "explicit field beats an auto-field, whatever the order",
+ "template": "{{appName}} / {{errorCode}}",
+ "matchedConditions": {
+ "with_auto": { "field": "failureType", "value": "x", "appName": "Auto-field value", "errorCode": "auto" },
+ "explicit_appname": { "field": "appName", "value": "Explicit value" },
+ "explicit_errorcode": { "field": "errorCode", "value": "explicit" }
+ },
+ "expected": "Explicit value / explicit"
+ },
+ {
+ "name": "two entries with the same field: the later one wins",
+ "template": "{{appName}}",
+ "matchedConditions": {
+ "first_app": { "field": "appName", "value": "First App" },
+ "second_app": { "field": "appName", "value": "Second App" }
+ },
+ "expected": "Second App"
+ },
+ {
+ "name": "field beats a signal of the same name",
+ "template": "{{reason}}",
+ "matchedConditions": {
+ "reason": { "field": "other", "value": "from signal" },
+ "enrollment_failed_reason": { "field": "reason", "value": "from field" }
+ },
+ "expected": "from field"
+ },
+ {
+ "name": "internal evidence keys are not tokens",
+ "template": "{{eventId}} {{sequence}} {{timestamp}} {{eventType}} {{field}} {{value}} {{count}}",
+ "matchedConditions": {
+ "sig": { "eventId": "evt-secret", "sequence": 42, "timestamp": "2026-05-28T10:00:00Z", "eventType": "app_install_failed", "field": "failureType", "value": "esp_apps_detection_failure", "count": 2 }
+ },
+ "expected": "{{eventId}} {{sequence}} {{timestamp}} {{eventType}} {{field}} {{value}} {{count}}"
+ },
+ {
+ "name": "prefixed correlation fields are not auto-fields",
+ "template": "{{appName}} {{eventA_appName}}",
+ "matchedConditions": {
+ "pair": { "eventA_appName": "Contoso VPN", "eventB_appName": "Contoso VPN", "totalMatches": 1 }
+ },
+ "expected": "{{appName}} {{eventA_appName}}"
+ },
+ {
+ "name": "null value renders empty",
+ "template": "[{{reason}}]",
+ "matchedConditions": { "sig": { "field": "reason", "value": null } },
+ "expected": "[]"
+ },
+ {
+ "name": "object and array values render as compact JSON",
+ "template": "{{detail}} {{names}}",
+ "matchedConditions": {
+ "a": { "field": "detail", "value": { "a": 1, "b": "x" } },
+ "b": { "field": "names", "value": ["one", "two"] }
+ },
+ "expected": "{\"a\":1,\"b\":\"x\"} [\"one\",\"two\"]"
+ },
+ {
+ "name": "the meta placeholder of the rule footnote is not a token",
+ "template": "Any field still shown as a literal `{{…}}` placeholder was not recorded: {{reason}}",
+ "matchedConditions": { "sig": { "field": "reason", "value": "max_lifetime" } },
+ "expected": "Any field still shown as a literal `{{…}}` placeholder was not recorded: max_lifetime"
+ }
+ ]
+}