Rules: optional presentation block, {{signal.key}} tokens, one resolution for web and MCP (D-356)

Analyze rules can describe how a finding is shown: a role (outcome, cause, context,
advisory), a one-line summary, the subject it is about and up to six key facts. Like the
explanation, the block is stored raw on the result and resolved where it is rendered.

- Templates: a qualified token {{signal.key}} reads one key of one condition's evidence,
  so counts, durations and correlation fields are reachable. RuleEvidenceKeys (Shared,
  exported to the manifest) lists the keys each condition source records; tests pin it
  against the evaluators.
- Backend: PresentationJson on AnalyzeRules and RuleResults, RuleVersion on results,
  ValidatePresentation on create/update, and results stored without a presentation get
  the current rule's at read time.
- Web + MCP: lib/rulePresentation.ts resolves the block (unrecorded facts left out, summary
  falls back to the explanation). The MCP now runs generated copies of the web modules
  instead of a hand-kept mirror (npm run generate:mcp-logic, freshness tests in both
  suites); the duplicate baseline shrinks by one.
- MCP output: get_session and get_session_summary return the resolved block (issues gain
  ruleId, role, summary, subject, facts); test_analyze_rule previews it; validate_rule
  checks qualified tokens against the key catalog and lints the subject.
- Shared case files: rule-template-interpolation gains the qualified-token cases,
  rule-presentation is new.

No rule carries a presentation yet; renderers behave as before until rules gain one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-13 22:48:46 +02:00
parent 4a822767d4
commit 516cdc71a7
46 changed files with 2523 additions and 151 deletions
@@ -55,6 +55,7 @@ public class RuleEngineArrayConditionTests
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-SEC-005", result.RuleId);
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(result.MatchedConditions["ppkg_not_allowlisted"]);
RuleEvidenceKeysTests.AssertDeclared("event_data_array", evidence);
Assert.Equal("identity", evidence["field"]);
Assert.Equal("Contoso.BulkEnroll.ppkg", evidence["value"]);
Assert.Equal(1, Convert.ToInt32(evidence["matchCount"]));
@@ -35,6 +35,7 @@ public class RuleEngineClockSkewTests
var result = Assert.Single(outcome.Results);
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(result.MatchedConditions["clock_jump"]);
RuleEvidenceKeysTests.AssertDeclared("clock_skew", evidence);
Assert.Equal("forward", evidence["direction"]);
Assert.Equal(1800, Convert.ToDouble(evidence["jumpSeconds"]), 0);
Assert.Equal("skewSummary", evidence["field"]);
@@ -188,6 +189,7 @@ public class RuleEngineClockSkewTests
var result = Assert.Single(outcome.Results);
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(result.MatchedConditions["sustained_offset"]);
RuleEvidenceKeysTests.AssertDeclared("clock_skew", evidence);
Assert.Equal("behind", evidence["direction"]);
Assert.Equal(-30.0, Convert.ToDouble(evidence["offsetMinutes"]), 1);
}
@@ -259,6 +261,7 @@ public class RuleEngineClockSkewTests
var result = Assert.Single(outcome.Results);
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(result.MatchedConditions["sustained_offset"]);
RuleEvidenceKeysTests.AssertDeclared("clock_skew", evidence);
Assert.Equal("behind", evidence["direction"]);
Assert.Equal(-30.0, Convert.ToDouble(evidence["offsetMinutes"]), 1);
Assert.Equal(5, Convert.ToInt32(evidence["sentAtBatchCount"]));
@@ -277,6 +280,7 @@ public class RuleEngineClockSkewTests
var result = Assert.Single(outcome.Results);
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(result.MatchedConditions["sustained_offset"]);
RuleEvidenceKeysTests.AssertDeclared("clock_skew", evidence);
Assert.Equal("behind", evidence["direction"]);
}
@@ -38,6 +38,7 @@ public class RuleEngineCorrelationEvidenceSlimTests
Assert.Single(outcome.Results);
var primary = (Dictionary<string, object>)outcome.Results[0].MatchedConditions["enforcement_resolved"];
RuleEvidenceKeysTests.AssertDeclared("event_correlation", primary);
Assert.False(primary.ContainsKey("eventA_message"), "free-text message must not leak into evidence");
Assert.False(primary.ContainsKey("eventB_message"), "free-text message must not leak into evidence");
@@ -69,6 +70,7 @@ public class RuleEngineCorrelationEvidenceSlimTests
Assert.Single(outcome.Results);
var primary = (Dictionary<string, object>)outcome.Results[0].MatchedConditions["enforcement_resolved"];
RuleEvidenceKeysTests.AssertDeclared("event_correlation", primary);
Assert.Equal(25, primary["totalMatches"]);
Assert.True(primary.ContainsKey("allMatches"));
@@ -91,6 +91,7 @@ public class RuleEngineDurationProvenanceTests
var condition = Assert.Single(dry.Conditions);
Assert.True(condition.Matched);
var evidence = AsDict(condition.Evidence!);
RuleEvidenceKeysTests.AssertDeclared("phase_duration", evidence);
Assert.Equal(3 * 3600d, Convert.ToDouble(evidence["durationSeconds"]));
Assert.Equal(events[1].EventId, AsString(evidence["phaseEndEventId"]));
Assert.False(evidence.ContainsKey("timestampProvenance"));
@@ -221,6 +222,7 @@ public class RuleEngineDurationProvenanceTests
var condition = Assert.Single(dry.Conditions);
Assert.True(condition.Matched);
RuleEvidenceKeysTests.AssertDeclared("app_install_duration", condition.Evidence);
Assert.Equal(3600d, Convert.ToDouble(AsDict(condition.Evidence!)["durationSeconds"]));
}
@@ -0,0 +1,118 @@
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins <see cref="RuleEvidenceKeys"/> against the evaluators. Every key an evidence entry carries
/// must be declared for its condition source; otherwise the MCP lint and the presentation ratchet
/// would reject a <c>{{signal.key}}</c> token that resolves, or pass one that never does. The simple
/// sources are covered here; the duration, correlation, clock-skew and array tests call
/// <see cref="AssertDeclared"/> on the evidence they already build.
/// </summary>
public class RuleEvidenceKeysTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "11111111-2222-3333-4444-555555555555";
internal static void AssertDeclared(string source, object? evidence)
{
var entry = Assert.IsAssignableFrom<IDictionary<string, object>>(evidence);
var declared = RuleEvidenceKeys.BySource[source];
var undeclared = entry.Keys.Where(k => !declared.Contains(k)).ToList();
Assert.True(undeclared.Count == 0, $"{source} evidence carries undeclared keys: {string.Join(", ", undeclared)}");
}
[Fact]
public void Catalog_covers_exactly_the_known_condition_sources()
{
Assert.Equal(
DryRunAnalyzeRuleFunction.KnownSources.OrderBy(s => s, StringComparer.Ordinal),
RuleEvidenceKeys.BySource.Keys.OrderBy(s => s, StringComparer.Ordinal));
}
[Fact]
public void Catalog_lists_every_key_once_per_source()
{
foreach (var (source, keys) in RuleEvidenceKeys.BySource)
Assert.True(keys.Count == keys.Distinct(StringComparer.Ordinal).Count(), $"{source} lists a key twice");
}
[Fact]
public void Engine_copies_the_catalog_auto_fields()
{
Assert.Equal(RuleEvidenceKeys.AutoFields, RuleEngine.EvidenceAutoFields);
}
[Theory]
[InlineData("event_type", "app_install_failed", "appName", "exists", "")]
[InlineData("event_type", "app_install_failed", "", "exists", "")]
[InlineData("event_type", "app_install_completed", "", "not_exists", "")]
[InlineData("event_type", "app_install_failed", "missingField", "not_exists", "")]
[InlineData("event_data", "app_install_failed", "appName", "equals", "Contoso VPN")]
[InlineData("event_count", "app_install_failed", "", "count_gte", "2")]
[InlineData("event_count", "app_install_failed", "appId", "count_per_group_gte", "2")]
public void Simple_sources_write_only_declared_keys(string source, string eventType, string dataField, string op, string value)
{
var condition = new RuleCondition
{
Signal = "sig",
Source = source,
EventType = eventType,
DataField = dataField,
Operator = op,
Value = value,
Required = true,
};
var dry = Engine().DryRunEvaluate(Rule(condition), Events());
var matched = Assert.IsAssignableFrom<IDictionary<string, object>>(dry.MatchedConditions);
Assert.True(matched.ContainsKey("sig"), $"{source}/{op} did not match the fixture");
AssertDeclared(source, matched["sig"]);
}
private static AnalyzeRule Rule(RuleCondition condition) => new()
{
RuleId = "TEST-EVIDENCE-001",
Title = "Evidence keys",
Severity = "warning",
Category = "apps",
Explanation = "x",
Conditions = new List<RuleCondition> { condition },
};
private static List<EnrollmentEvent> Events()
{
var appId = "00000000-0000-4000-8000-000000000003";
return Enumerable.Range(0, 2).Select(i => new EnrollmentEvent
{
EventId = $"evt-{i}",
TenantId = TenantId,
SessionId = SessionId,
EventType = "app_install_failed",
Timestamp = new DateTime(2026, 5, 28, 10, 16, 0, DateTimeKind.Utc).AddMinutes(i),
Sequence = 100 + i,
Data = new Dictionary<string, object>
{
["appId"] = appId,
["appName"] = "Contoso VPN",
["errorCode"] = "0x80d03805",
["errorPatternId"] = "IME-ERROR-REPORT",
["status"] = "Error",
},
}).ToList();
}
private static RuleEngine Engine()
{
var ruleRepo = new Mock<IRuleRepository>();
var sessionRepo = new Mock<ISessionRepository>();
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
return new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleEvidenceKeysTests>.Instance);
}
}
@@ -0,0 +1,298 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The optional presentation block of analyze rules: Store↔Map in both tables (every field in both
/// directions; a legacy row without the column reads back as null, never as an empty block), the
/// create/update validation, the read-time fallback for results stored without one, the engine
/// copying it onto a result, and the schema enums/limits matching the C# vocabularies.
/// </summary>
public class RulePresentationTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "11111111-2222-3333-4444-555555555555";
private static RulePresentation Sample() => new()
{
Role = RulePresentationRoles.Cause,
Summary = "{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times",
Subject = new RuleSubject { Kind = RuleSubjectKinds.App, Key = "{{install_retry_loop.groupKey}}", Name = "{{install_retry_loop.appName}}" },
Facts = new List<RuleFact>
{
new() { Label = "Install starts", Value = "{{install_retry_loop.count}}", Format = RuleFactFormats.Number },
new() { Label = "App", Value = "{{install_retry_loop.appName}}" },
},
};
// ── Store ↔ Map ────────────────────────────────────────────────────────
private static (TableStorageService service, Func<TableEntity?> lastUpserted) BuildStorageHarness()
{
TableEntity? captured = null;
var tableClient = new Mock<TableClient>();
tableClient
.Setup(c => c.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, _, _) =>
{
captured = e;
return Task.FromResult(new Mock<Response>().Object);
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(tableClient.Object);
return (new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance), () => captured);
}
[Fact]
public async Task AnalyzeRule_presentation_survives_store_and_map()
{
var (service, lastUpserted) = BuildStorageHarness();
var rule = new AnalyzeRule { RuleId = "ANALYZE-TEST-901", Title = "t", Severity = "high", Category = "apps", Explanation = "e", Presentation = Sample() };
Assert.True(await service.StoreAnalyzeRuleAsync(rule, "global"));
var mapped = service.MapToAnalyzeRule(lastUpserted()!);
AssertSamePresentation(Sample(), mapped.Presentation);
}
[Fact]
public async Task AnalyzeRule_without_presentation_stores_an_empty_column_and_maps_to_null()
{
var (service, lastUpserted) = BuildStorageHarness();
var rule = new AnalyzeRule { RuleId = "ANALYZE-TEST-902", Title = "t", Severity = "high", Category = "apps", Explanation = "e" };
Assert.True(await service.StoreAnalyzeRuleAsync(rule, "global"));
var entity = lastUpserted()!;
Assert.Equal(string.Empty, entity.GetString("PresentationJson"));
Assert.Null(service.MapToAnalyzeRule(entity).Presentation);
entity.Remove("PresentationJson");
Assert.Null(service.MapToAnalyzeRule(entity).Presentation);
}
[Fact]
public async Task RuleResult_presentation_and_version_survive_store_and_map()
{
var (service, lastUpserted) = BuildStorageHarness();
var result = new RuleResult
{
SessionId = SessionId, TenantId = TenantId, RuleId = "ANALYZE-APP-006", RuleTitle = "t", RuleVersion = "2.0.0",
Severity = "high", Category = "apps", Explanation = "e", Presentation = Sample(),
};
Assert.True(await service.StoreRuleResultAsync(result));
var mapped = service.MapToRuleResult(lastUpserted()!);
Assert.Equal("2.0.0", mapped.RuleVersion);
AssertSamePresentation(Sample(), mapped.Presentation);
}
[Fact]
public async Task Legacy_rule_result_maps_to_null_presentation_and_version()
{
var (service, lastUpserted) = BuildStorageHarness();
Assert.True(await service.StoreRuleResultAsync(new RuleResult { SessionId = SessionId, TenantId = TenantId, RuleId = "ANALYZE-APP-006", Explanation = "e" }));
var entity = lastUpserted()!;
entity.Remove("PresentationJson");
entity.Remove("RuleVersion");
var mapped = service.MapToRuleResult(entity);
Assert.Null(mapped.Presentation);
Assert.Null(mapped.RuleVersion);
}
// ── Validation ─────────────────────────────────────────────────────────
[Fact]
public void Valid_presentation_passes_and_blank_fact_rows_are_dropped()
{
var presentation = Sample();
presentation.Facts!.Add(new RuleFact { Label = " ", Value = "" });
var rule = new AnalyzeRule { Presentation = presentation };
AnalyzeRuleService.ValidatePresentation(rule);
Assert.Equal(2, rule.Presentation!.Facts!.Count);
}
[Fact]
public void Only_blank_fact_rows_leave_no_facts()
{
var rule = new AnalyzeRule { Presentation = new RulePresentation { Role = "advisory", Facts = new List<RuleFact> { new() { Label = "", Value = " " } } } };
AnalyzeRuleService.ValidatePresentation(rule);
Assert.Null(rule.Presentation!.Facts);
}
[Theory]
[InlineData("verdict", null, null, null, "presentation.role")]
[InlineData("cause", "", null, null, "presentation.summary")]
[InlineData("cause", null, "thing", null, "presentation.subject.kind")]
[InlineData("cause", null, "app", "weird", "presentation.facts[0].format")]
public void Invalid_presentation_is_rejected_naming_the_field(string role, string? summary, string? subjectKind, string? format, string expectedField)
{
var rule = new AnalyzeRule
{
Presentation = new RulePresentation
{
Role = role,
Summary = summary,
Subject = subjectKind == null ? null : new RuleSubject { Kind = subjectKind, Key = "{{a.b}}" },
Facts = format == null ? null : new List<RuleFact> { new() { Label = "L", Value = "{{a.b}}", Format = format } },
},
};
var ex = Assert.Throws<ArgumentException>(() => AnalyzeRuleService.ValidatePresentation(rule));
Assert.Contains(expectedField, ex.Message);
}
[Fact]
public void Too_many_facts_and_long_texts_are_rejected()
{
var rule = new AnalyzeRule
{
Presentation = new RulePresentation
{
Role = "cause",
Summary = new string('s', RulePresentationLimits.SummaryMaxLength + 1),
Facts = Enumerable.Range(0, RulePresentationLimits.MaxFacts + 1)
.Select(i => new RuleFact { Label = new string('l', RulePresentationLimits.LabelMaxLength + 1), Value = "v" }).ToList(),
},
};
var ex = Assert.Throws<ArgumentException>(() => AnalyzeRuleService.ValidatePresentation(rule));
Assert.Contains("presentation.summary", ex.Message);
Assert.Contains("at most", ex.Message);
Assert.Contains("presentation.facts[0].label", ex.Message);
}
// ── Read-time fallback ─────────────────────────────────────────────────
[Fact]
public async Task Results_without_presentation_get_the_current_rule_presentation()
{
var global = new AnalyzeRule { RuleId = "ANALYZE-APP-006", Presentation = Sample() };
var custom = new AnalyzeRule { RuleId = "CONTOSO-RULE-001", Presentation = new RulePresentation { Role = "advisory" } };
var stored = new RulePresentation { Role = "context" };
var results = new List<RuleResult>
{
new() { RuleId = "ANALYZE-APP-006" },
new() { RuleId = "CONTOSO-RULE-001" },
new() { RuleId = "ANALYZE-ESP-005", Presentation = stored },
new() { RuleId = "ANALYZE-GONE-001" },
};
await ServiceWith(new[] { global }, new[] { custom }).AttachCurrentPresentationsAsync(TenantId, results);
Assert.Equal("cause", results[0].Presentation?.Role);
Assert.Equal("advisory", results[1].Presentation?.Role);
Assert.Same(stored, results[2].Presentation);
Assert.Null(results[3].Presentation);
}
[Fact]
public async Task Results_that_all_carry_a_presentation_need_no_rule_lookup()
{
var ruleRepo = new Mock<IRuleRepository>(MockBehavior.Strict);
var service = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
await service.AttachCurrentPresentationsAsync(TenantId, new List<RuleResult> { new() { RuleId = "X", Presentation = Sample() } });
}
[Fact]
public async Task A_failed_rule_lookup_leaves_the_results_untouched()
{
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(It.IsAny<string>())).ThrowsAsync(new InvalidOperationException("storage down"));
var service = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var results = new List<RuleResult> { new() { RuleId = "ANALYZE-APP-006" } };
await service.AttachCurrentPresentationsAsync(TenantId, results);
Assert.Null(results[0].Presentation);
}
private static AnalyzeRuleService ServiceWith(IEnumerable<AnalyzeRule> global, IEnumerable<AnalyzeRule> tenant)
{
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(global.ToList());
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(tenant.ToList());
return new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
}
// ── Engine ─────────────────────────────────────────────────────────────
[Fact]
public async Task Engine_copies_presentation_and_version_onto_the_result()
{
// A shipped rule (the merge drops global rules outside the shipped catalog as sunset).
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-ENRL-001");
rule.Presentation = Sample();
var events = new List<EnrollmentEvent>
{
new() { EventId = "evt-1", TenantId = TenantId, SessionId = SessionId, EventType = "enrollment_failed", Timestamp = DateTime.UtcNow, Sequence = 1, Data = new Dictionary<string, object> { ["reason"] = "esp_terminal_failure" } },
};
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var engine = new RuleEngine(new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance), ruleRepo.Object, sessionRepo.Object, NullLogger<RulePresentationTests>.Instance);
var outcome = await engine.AnalyzeSessionAsync(TenantId, SessionId);
var result = Assert.Single(outcome.Results);
Assert.Equal(rule.Version, result.RuleVersion);
AssertSamePresentation(Sample(), result.Presentation);
}
// ── Schema ↔ C# vocabularies ───────────────────────────────────────────
[Fact]
public void Schema_enums_and_limits_match_the_shared_vocabularies()
{
using var schema = JsonDocument.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "rules", "schema", "analyze-rule.schema.json")));
var defs = schema.RootElement.GetProperty("$defs");
var presentation = defs.GetProperty("rulePresentation").GetProperty("properties");
var subject = defs.GetProperty("ruleSubject").GetProperty("properties");
var fact = defs.GetProperty("ruleFact").GetProperty("properties");
Assert.Equal(RulePresentationRoles.All, Strings(presentation.GetProperty("role").GetProperty("enum")));
Assert.Equal(RuleSubjectKinds.All, Strings(subject.GetProperty("kind").GetProperty("enum")));
Assert.Equal(RuleFactFormats.All, Strings(fact.GetProperty("format").GetProperty("enum")));
Assert.Equal(RulePresentationLimits.SummaryMaxLength, presentation.GetProperty("summary").GetProperty("maxLength").GetInt32());
Assert.Equal(RulePresentationLimits.MaxFacts, presentation.GetProperty("facts").GetProperty("maxItems").GetInt32());
Assert.Equal(RulePresentationLimits.LabelMaxLength, fact.GetProperty("label").GetProperty("maxLength").GetInt32());
Assert.Equal(RulePresentationLimits.TemplateMaxLength, fact.GetProperty("value").GetProperty("maxLength").GetInt32());
Assert.Equal(RulePresentationLimits.TemplateMaxLength, subject.GetProperty("key").GetProperty("maxLength").GetInt32());
Assert.Equal(RulePresentationLimits.TemplateMaxLength, subject.GetProperty("name").GetProperty("maxLength").GetInt32());
}
private static string[] Strings(JsonElement array) => array.EnumerateArray().Select(e => e.GetString()!).ToArray();
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
return dir?.FullName ?? throw new InvalidOperationException("repository root (AutopilotMonitor.sln) not found");
}
private static void AssertSamePresentation(RulePresentation expected, RulePresentation? actual)
{
Assert.NotNull(actual);
Assert.Equal(Newtonsoft.Json.JsonConvert.SerializeObject(expected), Newtonsoft.Json.JsonConvert.SerializeObject(actual));
}
}
@@ -147,6 +147,8 @@ public sealed class SharedManifestParityTests
["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)),
["analyzeRuleSources"] = DryRunAnalyzeRuleFunction.KnownSources.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
["analyzeRuleOperators"] = DryRunAnalyzeRuleFunction.KnownOperators.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
// Evidence keys per condition source: what a {{signal.key}} token can resolve (MCP rule lint).
["analyzeRuleEvidenceKeys"] = RuleEvidenceKeys.BySource,
["eventTypes"] = ConstStrings(typeof(Constants.EventTypes)).OrderBy(s => s, StringComparer.Ordinal).ToArray(),
// Declaration order kept: the web derives a union type from this list, order is cosmetic
// but a stable order keeps the generated file diff-minimal.
@@ -111,6 +111,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules
}
var results = await _ruleRepo.GetRuleResultsAsync(effectiveTenantId, sessionId);
await _analyzeRuleService.AttachCurrentPresentationsAsync(effectiveTenantId, results);
// Resolved findings (session healed / no longer firing) are kept for audit and still
// returned in `results` (the UI hides them behind a toggle), but they no longer count
@@ -1,3 +1,4 @@
using AutopilotMonitor.Functions.Services.Caching;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
@@ -49,6 +50,11 @@ namespace AutopilotMonitor.Functions.Services
private HashSet<string>? _liveCatalogIds;
private readonly object _liveCatalogLock = new();
// Current presentations of the global rules for the read-time fallback in
// AttachCurrentPresentationsAsync. A reseed or edit shows up on legacy rows after the TTL.
private static readonly TimeSpan PresentationCacheTtl = TimeSpan.FromMinutes(5);
private readonly SingleFlightCache<IReadOnlyDictionary<string, RulePresentation>> _presentationCache = new(maxEntries: 1);
public AnalyzeRuleService(IRuleRepository ruleRepo, ILogger<AnalyzeRuleService> logger, BackendBuildInfo? buildInfo = null)
{
_ruleRepo = ruleRepo;
@@ -160,6 +166,56 @@ namespace AutopilotMonitor.Functions.Services
return (SunsetOutcome.Completed, gcDeleted);
}
/// <summary>
/// Gives stored results without a presentation the current presentation of their rule: rows
/// written before the field existed, or before the rule gained one. Read time only, never
/// persisted — the stored row stays the snapshot of its evaluation (D-302). Global rules come
/// from a per-instance cache; tenant custom rules are read when a result needs them.
/// Fail-soft: a failed lookup leaves the results as they are.
/// </summary>
public async Task AttachCurrentPresentationsAsync(string tenantId, IReadOnlyList<RuleResult> results)
{
if (!results.Any(r => r.Presentation == null))
return;
try
{
var global = await _presentationCache.GetOrAddAsync(
"global", PresentationCacheTtl, () => LoadPresentationsAsync("global"));
IReadOnlyDictionary<string, RulePresentation>? tenant = null;
foreach (var result in results)
{
if (result.Presentation != null)
continue;
if (global.TryGetValue(result.RuleId, out var presentation))
{
result.Presentation = presentation;
continue;
}
tenant ??= await LoadPresentationsAsync(tenantId);
if (tenant.TryGetValue(result.RuleId, out presentation))
result.Presentation = presentation;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Attaching current rule presentations failed for tenant {TenantId}; results returned without them", tenantId);
}
}
private async Task<IReadOnlyDictionary<string, RulePresentation>> LoadPresentationsAsync(string partitionKey)
{
var rules = await _ruleRepo.GetAnalyzeRulesAsync(partitionKey);
var byRule = new Dictionary<string, RulePresentation>(StringComparer.Ordinal);
foreach (var rule in rules)
{
if (rule.Presentation != null)
byRule[rule.RuleId] = rule.Presentation;
}
return byRule;
}
/// <summary>
/// Gets all active analyze rules for a tenant (enabled only)
/// </summary>
@@ -277,6 +333,7 @@ namespace AutopilotMonitor.Functions.Services
ValidateEvaluateOn(rule);
ValidateRelatedDocs(rule);
ValidatePresentation(rule);
rule.IsBuiltIn = false;
rule.IsCommunity = false;
@@ -315,6 +372,7 @@ namespace AutopilotMonitor.Functions.Services
ValidateEvaluateOn(rule);
ValidateRelatedDocs(rule);
ValidatePresentation(rule);
// Custom rules are fully tenant-owned: fold a notify override into the row default
// (there is no separate RuleState for them) — mirrors how the UI edits Enabled.
@@ -711,6 +769,62 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Checks the optional presentation block against the vocabularies and limits the rule schema
/// declares. The body reader is lenient, so without this a custom rule could store a role or
/// format no renderer knows. Fact rows left blank by an editor are dropped. Throws
/// <see cref="ArgumentException"/> (mapped to 400) naming every offending field.
/// </summary>
internal static void ValidatePresentation(AnalyzeRule rule)
{
var p = rule.Presentation;
if (p == null)
return;
var errors = new List<string>();
if (!RulePresentationRoles.All.Contains(p.Role))
errors.Add($"presentation.role '{p.Role}' must be one of: {string.Join(", ", RulePresentationRoles.All)}");
if (p.Summary != null && !IsTemplateWithin(p.Summary, RulePresentationLimits.SummaryMaxLength))
errors.Add($"presentation.summary must be 1-{RulePresentationLimits.SummaryMaxLength} characters");
if (p.Subject != null)
{
if (!RuleSubjectKinds.All.Contains(p.Subject.Kind))
errors.Add($"presentation.subject.kind '{p.Subject.Kind}' must be one of: {string.Join(", ", RuleSubjectKinds.All)}");
if (!IsTemplateWithin(p.Subject.Key, RulePresentationLimits.TemplateMaxLength))
errors.Add($"presentation.subject.key must be 1-{RulePresentationLimits.TemplateMaxLength} characters");
if (p.Subject.Name != null && !IsTemplateWithin(p.Subject.Name, RulePresentationLimits.TemplateMaxLength))
errors.Add($"presentation.subject.name must be 1-{RulePresentationLimits.TemplateMaxLength} characters");
}
if (p.Facts != null)
{
p.Facts = p.Facts
.Where(f => f != null && !(string.IsNullOrWhiteSpace(f.Label) && string.IsNullOrWhiteSpace(f.Value)))
.ToList();
if (p.Facts.Count > RulePresentationLimits.MaxFacts)
errors.Add($"presentation.facts allows at most {RulePresentationLimits.MaxFacts} entries");
for (var i = 0; i < p.Facts.Count; i++)
{
var fact = p.Facts[i];
if (!IsTemplateWithin(fact.Label, RulePresentationLimits.LabelMaxLength))
errors.Add($"presentation.facts[{i}].label must be 1-{RulePresentationLimits.LabelMaxLength} characters");
if (!IsTemplateWithin(fact.Value, RulePresentationLimits.TemplateMaxLength))
errors.Add($"presentation.facts[{i}].value must be 1-{RulePresentationLimits.TemplateMaxLength} characters");
if (fact.Format != null && !RuleFactFormats.All.Contains(fact.Format))
errors.Add($"presentation.facts[{i}].format '{fact.Format}' must be one of: {string.Join(", ", RuleFactFormats.All)}");
}
if (p.Facts.Count == 0)
p.Facts = null;
}
if (errors.Count > 0)
throw new ArgumentException("Invalid presentation: " + string.Join("; ", errors));
}
private static bool IsTemplateWithin(string? text, int maxLength)
=> !string.IsNullOrWhiteSpace(text) && text.Length <= maxLength;
internal static bool IsAbsoluteHttpUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
@@ -1308,15 +1308,12 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// 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
/// <c>{{token}}</c> — the web, the MCP server and <see cref="RuleTemplateInterpolator"/>
/// carry the same list, pinned by tests/fixtures/rule-template-interpolation/cases.json.
/// Same-event fields copied into every evidence entry (<see cref="RuleEvidenceKeys.AutoFields"/>).
/// Every renderer resolves these as <c>{{token}}</c> — the web, the MCP server and
/// <see cref="RuleTemplateInterpolator"/> carry the same list, pinned by
/// tests/fixtures/rule-template-interpolation/cases.json.
/// </summary>
internal static readonly IReadOnlyList<string> EvidenceAutoFields = new[]
{
"appId", "appName", "errorPatternId", "errorCode", "exitCode", "status", "timeoutHours"
};
internal static IReadOnlyList<string> EvidenceAutoFields => RuleEvidenceKeys.AutoFields;
/// <summary>
/// Adds slim identifying data fields from an event to the evidence dictionary with a prefix.
@@ -395,12 +395,14 @@ namespace AutopilotMonitor.Functions.Services
{
RuleId = rule.RuleId,
RuleTitle = rule.Title,
RuleVersion = rule.Version,
Severity = rule.Severity,
Category = rule.Category,
ConfidenceScore = confidence,
Explanation = rule.Explanation,
Remediation = rule.Remediation,
RelatedDocs = rule.RelatedDocs,
Presentation = rule.Presentation,
MatchedConditions = matchedConditions,
DetectedAt = DateTime.UtcNow
};
@@ -26,6 +26,11 @@ namespace AutopilotMonitor.Functions.Services
/// <item>an evidence entry whose signal name equals the token;</item>
/// <item>otherwise the token stays as written.</item>
/// </list>
/// <para>
/// A qualified token <c>{{signal.key}}</c> reads one key of the evidence entry named
/// <c>signal</c> (counts, durations, correlation fields) without any fallback: an unknown
/// signal or key, a scalar entry or a null value leaves it unresolved.
/// </para>
/// </summary>
public static class RuleTemplateInterpolator
{
@@ -33,7 +38,7 @@ namespace AutopilotMonitor.Functions.Services
internal const string NotRecorded = "–";
private static readonly Regex TokenRegex = new Regex(
@"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
@"\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_]+))?\s*\}\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex ListItemRegex = new Regex(
@"^\s*(?:[-*+]|\d+[.)])\s+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
@@ -54,7 +59,7 @@ namespace AutopilotMonitor.Functions.Services
return string.Empty;
var lookup = Lookup.From(matchedConditions);
return TokenRegex.Replace(text, m => lookup.Resolve(m.Groups[1].Value) ?? m.Value);
return TokenRegex.Replace(text, m => lookup.Resolve(m) ?? m.Value);
}
/// <summary>
@@ -76,7 +81,7 @@ namespace AutopilotMonitor.Functions.Services
var unresolved = false;
var rendered = TokenRegex.Replace(line, m =>
{
var value = lookup.Resolve(m.Groups[1].Value);
var value = lookup.Resolve(m);
if (value != null)
return value;
unresolved = true;
@@ -105,15 +110,31 @@ namespace AutopilotMonitor.Functions.Services
private readonly Dictionary<string, string> _byField = new Dictionary<string, string>(StringComparer.Ordinal);
private readonly Dictionary<string, string> _byAutoField = new Dictionary<string, string>(StringComparer.Ordinal);
private readonly Dictionary<string, string> _bySignal = new Dictionary<string, string>(StringComparer.Ordinal);
private JObject? _root;
public string? Resolve(string token)
/// <summary>Resolves a <see cref="TokenRegex"/> match: group 1 is the token, group 2 the optional key.</summary>
public string? Resolve(Match match)
{
var token = match.Groups[1].Value;
if (match.Groups[2].Success)
return ResolveQualified(token, match.Groups[2].Value);
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;
}
private string? ResolveQualified(string signal, string key)
{
if (_root?[signal] is not JObject evidence
|| !evidence.TryGetValue(key, StringComparison.Ordinal, out var token)
|| token == null
|| token.Type is JTokenType.Null or JTokenType.Undefined)
return null;
return Format(token);
}
public static Lookup From(IReadOnlyDictionary<string, object>? matchedConditions)
{
var lookup = new Lookup();
@@ -135,6 +156,7 @@ namespace AutopilotMonitor.Functions.Services
return lookup;
}
lookup._root = root;
foreach (var property in root.Properties())
{
if (property.Value is not JObject evidence)
@@ -85,12 +85,15 @@ namespace AutopilotMonitor.Functions.Services
["TenantId"] = result.TenantId,
["RuleId"] = result.RuleId,
["RuleTitle"] = result.RuleTitle ?? string.Empty,
["RuleVersion"] = result.RuleVersion ?? string.Empty,
["Severity"] = result.Severity ?? string.Empty,
["Category"] = result.Category ?? string.Empty,
["ConfidenceScore"] = result.ConfidenceScore,
["Explanation"] = result.Explanation ?? string.Empty,
["RemediationJson"] = JsonConvert.SerializeObject(result.Remediation ?? new List<RemediationStep>()),
["RelatedDocsJson"] = JsonConvert.SerializeObject(result.RelatedDocs ?? new List<RelatedDoc>()),
// Empty string, never null: the upsert merges, and a skipped null would keep a stale block.
["PresentationJson"] = SerializePresentation(result.Presentation),
["MatchedConditionsJson"] = matchedJson,
["DetectedAt"] = result.DetectedAt,
// Evaluation lifecycle (evaluateOn interim triggers). Nullable columns:
@@ -129,29 +132,7 @@ namespace AutopilotMonitor.Functions.Services
var results = new List<RuleResult>();
await foreach (var entity in query)
{
results.Add(new RuleResult
{
ResultId = entity.GetString("ResultId") ?? string.Empty,
SessionId = entity.GetString("SessionId") ?? string.Empty,
TenantId = entity.GetString("TenantId") ?? string.Empty,
RuleId = entity.GetString("RuleId") ?? entity.RowKey,
RuleTitle = entity.GetString("RuleTitle") ?? string.Empty,
Severity = entity.GetString("Severity") ?? string.Empty,
Category = entity.GetString("Category") ?? string.Empty,
ConfidenceScore = entity.GetInt32("ConfidenceScore") ?? 0,
Explanation = entity.GetString("Explanation") ?? string.Empty,
Remediation = DeserializeJson<List<RemediationStep>>(entity.GetString("RemediationJson")),
RelatedDocs = DeserializeJson<List<RelatedDoc>>(entity.GetString("RelatedDocsJson")),
MatchedConditions = DeserializeMatchedConditions(entity.GetString("MatchedConditionsJson")),
DetectedAt = entity.GetDateTimeOffset("DetectedAt")?.UtcDateTime ?? DateTime.UtcNow,
FirstDetectedAt = entity.GetDateTimeOffset("FirstDetectedAt")?.UtcDateTime,
LastEvaluatedAt = entity.GetDateTimeOffset("LastEvaluatedAt")?.UtcDateTime,
IsInterim = entity.GetBoolean("IsInterim") ?? false,
ResolvedAt = entity.GetDateTimeOffset("ResolvedAt")?.UtcDateTime,
NotifiedAt = entity.GetDateTimeOffset("NotifiedAt")?.UtcDateTime
});
}
results.Add(MapToRuleResult(entity));
return results.OrderByDescending(r => r.ConfidenceScore).ToList();
}
@@ -162,6 +143,55 @@ namespace AutopilotMonitor.Functions.Services
}
}
// Internal (not private) so the Store↔Map roundtrip is testable per the table-serialization
// rule: every model field must appear in BOTH directions. Resolved rows are re-stored from
// this mapping (RuleEngine, reanalyze), so a column it skips would be wiped on the rewrite.
internal RuleResult MapToRuleResult(TableEntity entity) => new RuleResult
{
ResultId = entity.GetString("ResultId") ?? string.Empty,
SessionId = entity.GetString("SessionId") ?? string.Empty,
TenantId = entity.GetString("TenantId") ?? string.Empty,
RuleId = entity.GetString("RuleId") ?? entity.RowKey,
RuleTitle = entity.GetString("RuleTitle") ?? string.Empty,
RuleVersion = string.IsNullOrEmpty(entity.GetString("RuleVersion")) ? null : entity.GetString("RuleVersion"),
Severity = entity.GetString("Severity") ?? string.Empty,
Category = entity.GetString("Category") ?? string.Empty,
ConfidenceScore = entity.GetInt32("ConfidenceScore") ?? 0,
Explanation = entity.GetString("Explanation") ?? string.Empty,
Remediation = DeserializeJson<List<RemediationStep>>(entity.GetString("RemediationJson")),
RelatedDocs = DeserializeJson<List<RelatedDoc>>(entity.GetString("RelatedDocsJson")),
Presentation = DeserializePresentation(entity.GetString("PresentationJson")),
MatchedConditions = DeserializeMatchedConditions(entity.GetString("MatchedConditionsJson")),
DetectedAt = entity.GetDateTimeOffset("DetectedAt")?.UtcDateTime ?? DateTime.UtcNow,
FirstDetectedAt = entity.GetDateTimeOffset("FirstDetectedAt")?.UtcDateTime,
LastEvaluatedAt = entity.GetDateTimeOffset("LastEvaluatedAt")?.UtcDateTime,
IsInterim = entity.GetBoolean("IsInterim") ?? false,
ResolvedAt = entity.GetDateTimeOffset("ResolvedAt")?.UtcDateTime,
NotifiedAt = entity.GetDateTimeOffset("NotifiedAt")?.UtcDateTime
};
/// <summary>Absent presentation is stored as an empty string (the upserts merge and skip nulls).</summary>
private static string SerializePresentation(RulePresentation? presentation)
=> presentation == null ? string.Empty : JsonConvert.SerializeObject(presentation);
/// <summary>
/// Empty or unreadable column → null. Never <c>new RulePresentation()</c>: a legacy row would
/// otherwise ship an empty block and hide the read-time fallback.
/// </summary>
private static RulePresentation? DeserializePresentation(string? json)
{
if (string.IsNullOrEmpty(json))
return null;
try
{
return JsonConvert.DeserializeObject<RulePresentation>(json);
}
catch (Newtonsoft.Json.JsonException)
{
return null;
}
}
/// <summary>
/// F3 radar hit set: sessionIds where <paramref name="ruleId"/> produced a result at/after
/// <paramref name="sinceUtc"/>. RuleResults PK = "{tenantId}_{sessionId}", RK = ruleId, so a
@@ -613,6 +643,7 @@ namespace AutopilotMonitor.Functions.Services
["Explanation"] = rule.Explanation ?? string.Empty,
["RemediationJson"] = JsonConvert.SerializeObject(rule.Remediation ?? new List<RemediationStep>()),
["RelatedDocsJson"] = JsonConvert.SerializeObject(rule.RelatedDocs ?? new List<RelatedDoc>()),
["PresentationJson"] = SerializePresentation(rule.Presentation),
["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]),
["TemplateVariablesJson"] = JsonConvert.SerializeObject(rule.TemplateVariables ?? new List<TemplateVariable>()),
["DerivedFromTemplateRuleId"] = rule.DerivedFromTemplateRuleId ?? string.Empty,
@@ -727,7 +758,8 @@ namespace AutopilotMonitor.Functions.Services
}
}
private AnalyzeRule MapToAnalyzeRule(TableEntity entity)
// Internal (not private) so the Store↔Map roundtrip is testable (table-serialization rule).
internal AnalyzeRule MapToAnalyzeRule(TableEntity entity)
{
var derivedFrom = entity.GetString("DerivedFromTemplateRuleId");
return new AnalyzeRule
@@ -756,6 +788,7 @@ namespace AutopilotMonitor.Functions.Services
Explanation = entity.GetString("Explanation") ?? string.Empty,
Remediation = DeserializeJson<List<RemediationStep>>(entity.GetString("RemediationJson")),
RelatedDocs = DeserializeJson<List<RelatedDoc>>(entity.GetString("RelatedDocsJson")),
Presentation = DeserializePresentation(entity.GetString("PresentationJson")),
Tags = DeserializeJsonArray(entity.GetString("TagsJson")),
TemplateVariables = DeserializeJson<List<TemplateVariable>>(entity.GetString("TemplateVariablesJson")),
DerivedFromTemplateRuleId = string.IsNullOrEmpty(derivedFrom) ? null : derivedFrom,
@@ -0,0 +1,124 @@
/**
* The presentation block of analyze-rule findings in MCP output: the shared resolution cases
* (tests/fixtures/rule-presentation, run by web and MCP), the in-place resolution of an analysis
* payload, and the issue shape of get_session_summary (ruleId, role, summary, subject, facts).
*
* Pure unit tests: fetch is stubbed and the registered tool handler is invoked directly.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { McpServer } from '@modelcontextprotocol/server';
import { registerTools } from '../tools.js';
import { runWithCaller } from '../client.js';
import { interpolateAnalysisResults, interpolateRuleTemplate } from '../interpolate-rule-template.js';
import { resolvePresentation, type PresentationTemplate } from '../generated/rule-presentation.generated.js';
interface PresentationCase {
name: string;
presentation: PresentationTemplate | null;
matchedConditions: Record<string, unknown>;
explanation: string;
expected: unknown;
}
function loadCases(): PresentationCase[] {
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;
}
const file = join(dir, 'tests', 'fixtures', 'rule-presentation', 'cases.json');
return (JSON.parse(readFileSync(file, 'utf8')) as { cases: PresentationCase[] }).cases;
}
describe('resolvePresentation — shared cases (web = MCP)', () => {
const cases = loadCases();
it.each(cases.map((c) => [c.name, c] as const))('%s', (_name, c) => {
expect(resolvePresentation(c.presentation, c.matchedConditions, c.explanation, interpolateRuleTemplate)).toEqual(c.expected);
});
});
const retryLoopResult = () => ({
ruleId: 'ANALYZE-APP-006',
ruleTitle: 'App Install Retry Loop',
severity: 'high',
explanation: 'The same app was started 3 or more times.',
remediation: [],
presentation: {
role: 'cause',
summary: '{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times',
subject: { kind: 'app', key: '{{install_retry_loop.groupKey}}', name: '{{install_retry_loop.appName}}' },
facts: [
{ label: 'Install starts', value: '{{install_retry_loop.count}}', format: 'number' },
{ label: 'Duration', value: '{{install_retry_loop.durationSeconds}}', format: 'duration' },
],
},
matchedConditions: {
install_retry_loop: { eventId: 'evt-3', groupKey: '00000000-0000-4000-8000-000000000002', appName: 'Contoso VPN', count: 4, threshold: 3 },
},
});
describe('interpolateAnalysisResults', () => {
it('replaces the presentation by its resolved form and drops facts that were not recorded', () => {
const analysis = { results: [retryLoopResult()] };
interpolateAnalysisResults(analysis);
expect(analysis.results[0].presentation).toEqual({
role: 'cause',
summary: 'Contoso VPN was started 4 times',
subject: { kind: 'app', key: '00000000-0000-4000-8000-000000000002', name: 'Contoso VPN' },
facts: [{ label: 'Install starts', value: '4', format: 'number' }],
});
expect(JSON.stringify(analysis)).not.toContain('{{');
});
});
type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }>;
const SESSION = 'e259c121-1234-4abc-9def-0123456789ab';
const GA = { token: 'ga', isGlobalAdmin: true };
const extra = { signal: new AbortController().signal };
function summaryHandler(): ToolHandler {
const server = new McpServer({ name: 'test', version: '0.0.0' });
registerTools(server, undefined, undefined, undefined, true, true, false);
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
return registry['get_session_summary'].handler;
}
afterEach(() => vi.unstubAllGlobals());
describe('get_session_summary issues', () => {
it('carry ruleId and the resolved presentation; a finding without one keeps the old shape', async () => {
const plain = { ruleId: 'ANALYZE-DEV-001', ruleTitle: 'Plain', severity: 'warning', explanation: 'Text.', remediation: [], matchedConditions: {} };
const body = {
success: true,
session: { sessionId: SESSION, tenantId: 't', status: 'Failed', startedAt: '2026-10-13T11:55:46Z' },
events: [],
results: [retryLoopResult(), plain],
annotations: [],
count: 0,
};
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) }) as unknown as Response));
const result = await runWithCaller(GA, () => summaryHandler()({ sessionId: SESSION }, extra));
const summary = JSON.parse((result.content ?? []).map((c) => c.text ?? '').join(''));
const [retry, other] = summary.analysis.issues;
expect(retry).toMatchObject({
ruleId: 'ANALYZE-APP-006',
role: 'cause',
summary: 'Contoso VPN was started 4 times',
subject: { kind: 'app', name: 'Contoso VPN' },
facts: [{ label: 'Install starts', value: '4', format: 'number' }],
});
expect(other.ruleId).toBe('ANALYZE-DEV-001');
expect(other.role).toBeUndefined();
expect(other.facts).toBeUndefined();
});
});
@@ -484,6 +484,54 @@ describe('analyze semantic lint', () => {
expect(warnings(validateRuleDraft(arrayRule).findings).some((m) => m.includes('{{identity}}'))).toBe(false);
});
it('qualified {{signal.key}} tokens resolve only from keys their condition source records', () => {
const rule = validAnalyze();
rule.explanation = 'Started {{app_retry_storm.count}} times (threshold {{app_retry_storm.threshold}}).';
expect(warnings(validateRuleDraft(rule).findings).filter((m) => m.includes('{{'))).toEqual([]);
rule.explanation = 'Took {{app_retry_storm.durationSeconds}} s; {{missing_signal.count}}.';
const w = warnings(validateRuleDraft(rule).findings);
expect(w.some((m) => m.includes('{{app_retry_storm.durationSeconds}}') && m.includes('never records "durationSeconds"'))).toBe(true);
expect(w.some((m) => m.includes('{{missing_signal.count}}') && m.includes('no condition has the signal'))).toBe(true);
});
it('presentation templates are linted like the explanation and need a valid shape', () => {
const rule = validAnalyze();
rule.presentation = {
role: 'cause',
summary: '{{app_retry_storm.appName}} was started {{app_retry_storm.count}} times',
subject: { kind: 'app', key: '{{app_retry_storm.groupKey}}', name: '{{app_retry_storm.appName}}' },
facts: [{ label: 'Install starts', value: '{{app_retry_storm.count}}', format: 'number' }],
};
const ok = validateRuleDraft(rule);
expect(errors(ok.findings)).toEqual([]);
expect(warnings(ok.findings).filter((m) => m.includes('{{') || m.includes('presentation'))).toEqual([]);
expect(ok.findings.some((f) => f.message.startsWith('No presentation block'))).toBe(false);
rule.presentation = { role: 'cause', facts: [{ label: 'Bad', value: '{{nope.count}}' }] };
expect(warnings(validateRuleDraft(rule).findings).some((m) => m.startsWith('presentation.facts[0].value references {{nope.count}}'))).toBe(true);
rule.presentation = { role: 'verdict' };
expect(errors(validateRuleDraft(rule).findings).some((m) => m.includes('role'))).toBe(true);
});
it('a subject must read one signal through qualified tokens', () => {
const rule = validAnalyze();
rule.presentation = { role: 'cause', subject: { kind: 'app', key: '{{appId}}', name: '{{appName}}' } };
expect(warnings(validateRuleDraft(rule).findings).some((m) => m.includes('bare token'))).toBe(true);
rule.conditions = [
...(rule.conditions as unknown[]),
{ signal: 'failed', source: 'event_type', eventType: 'app_install_failed', dataField: 'appName', operator: 'exists', value: '' },
];
rule.presentation = { role: 'cause', subject: { kind: 'app', key: '{{app_retry_storm.groupKey}}', name: '{{failed.appName}}' } };
expect(warnings(validateRuleDraft(rule).findings).some((m) => m.includes('different signals'))).toBe(true);
});
it('a rule without a presentation block gets an info hint', () => {
expect(validateRuleDraft(validAnalyze()).findings.some((f) => f.level === 'info' && f.message.startsWith('No presentation block'))).toBe(true);
});
it('markSessionAsFailedDefault=true yields an info finding', () => {
const ko = validAnalyze();
ko.markSessionAsFailedDefault = true;
@@ -0,0 +1,45 @@
/**
* Freshness guard for the web modules the MCP runs as copies (src/generated/*.generated.ts,
* written by the web's scripts/generate-mcp-shared-logic.js). A web edit that skips
* `npm run generate:mcp-logic` goes red here.
*
* Same-repo only: skipped when the web project can't be located (Docker build).
*/
import { describe, it, expect } from 'vitest';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
function findWebRoot(): string | null {
let dir = dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 12; i++) {
if (existsSync(join(dir, 'AutopilotMonitor.sln'))) {
const p = join(dir, 'src', 'Web', 'autopilot-monitor-web');
return existsSync(p) ? p : null;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
describe('generated shared logic freshness (MCP copies)', () => {
const webRoot = findWebRoot();
const itOrSkip = webRoot ? it : it.skip;
itOrSkip('every copied web module matches its source', () => {
const require = createRequire(import.meta.url);
const { MODULES, buildMcpLogicCopy } = require(join(webRoot!, 'scripts', 'generate-mcp-shared-logic.js')) as {
MODULES: { source: string; dest: string }[];
buildMcpLogicCopy: (source: string, text: string) => string;
};
expect(MODULES.length).toBeGreaterThan(0);
for (const m of MODULES) {
const sourceText = readFileSync(join(webRoot!, m.source), 'utf8');
const committed = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'generated', m.dest), 'utf8');
expect(committed.replace(/\r\n/g, '\n'), m.dest).toBe(buildMcpLogicCopy(m.source, sourceText));
}
});
});
@@ -0,0 +1,113 @@
// GENERATED — do not edit by hand. Copy of a web module for the MCP server.
// Source: src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts
// Regenerate: npm run generate:mcp-logic in src/Web/autopilot-monitor-web.
/**
* Replaces `{{token}}` placeholders in a rule's explanation/remediation text
* with values pulled from the rule's `matchedConditions` evidence map.
*
* Resolution order for `{{token}}`:
* 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 —
* `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`
* (e.g. {{ca2023_not_updated}} -> that signal's value).
* 4. Unresolved tokens are left untouched so authors notice the typo.
*
* A qualified token `{{signal.key}}` reads `key` of the evidence entry named `signal` (counts,
* durations, correlation fields). It has no fallback: an unknown signal or key, a scalar
* entry or a null value leaves it literal.
*
* Token chars: [a-zA-Z0-9_], one optional `.key`. 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<string, unknown> | null | undefined
): string {
if (!text) return text ?? "";
if (!matchedConditions || typeof matchedConditions !== "object") return text;
const byField = new Map<string, string>();
const byAutoField = new Map<string, string>();
const bySignal = new Map<string, string>();
for (const [signal, evidence] of Object.entries(matchedConditions)) {
if (!evidence || typeof evidence !== "object") {
if (evidence != null) bySignal.set(signal, formatValue(evidence));
continue;
}
const e = evidence as Record<string, unknown>;
if (typeof e.field === "string" && "value" in e) {
byField.set(e.field, formatValue(e.value));
}
if ("value" in e) {
bySignal.set(signal, formatValue(e.value));
}
// 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 RULE_TEMPLATE_AUTO_FIELDS) {
if (byAutoField.has(af)) continue;
const v = e[af];
if (v != null && v !== "") byAutoField.set(af, formatValue(v));
}
}
return text.replace(
/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_]+))?\s*\}\}/g,
(raw, token: string, key: string | undefined) => {
if (key !== undefined) return qualifiedValue(matchedConditions, token, key) ?? raw;
if (byField.has(token)) return byField.get(token)!;
if (byAutoField.has(token)) return byAutoField.get(token)!;
if (bySignal.has(token)) return bySignal.get(token)!;
return raw;
}
);
}
/** `{{signal.key}}`: own properties only, so `constructor`/`toString` never resolve. */
function qualifiedValue(matchedConditions: Record<string, unknown>, signal: string, key: string): string | null {
if (!Object.prototype.hasOwnProperty.call(matchedConditions, signal)) return null;
const entry = matchedConditions[signal];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
if (!Object.prototype.hasOwnProperty.call(entry, key)) return null;
const v = (entry as Record<string, unknown>)[key];
return v == null ? null : formatValue(v);
}
function formatValue(v: unknown): string {
if (v == null) return "";
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}
@@ -0,0 +1,88 @@
// GENERATED — do not edit by hand. Copy of a web module for the MCP server.
// Source: src/Web/autopilot-monitor-web/lib/rulePresentation.ts
// Regenerate: npm run generate:mcp-logic in src/Web/autopilot-monitor-web.
/**
* Resolves an analyze rule's presentation block (role, summary, subject, key facts) against one
* finding's evidence. Like the explanation, a stored finding keeps the raw `{{token}}` templates
* and every renderer resolves them; this module is the one resolution of the presentation, pinned
* by `tests/fixtures/rule-presentation/cases.json` at the repo root:
* - a fact whose value does not resolve (token not recorded, or empty) is left out;
* - the summary falls back to the first sentence of the explanation;
* - a subject whose key does not resolve is no subject; a missing name shows the key;
* - an absent or unknown fact format reads as "text".
*
* Self-contained on purpose: the MCP server runs a generated copy (npm run generate:mcp-logic),
* so the interpolation is passed in instead of imported.
*/
export interface PresentationTemplate {
role: string;
summary?: string | null;
subject?: { kind: string; key: string; name?: string | null } | null;
facts?: { label: string; value: string; format?: string | null }[] | null;
}
export interface ResolvedFact {
label: string;
value: string;
format: string;
}
export interface ResolvedPresentation {
role: string;
summary: string | null;
subject: { kind: string; key: string; name: string } | null;
facts: ResolvedFact[];
}
type Interpolate = (text: string, matchedConditions: Record<string, unknown> | null | undefined) => string;
const FACT_FORMATS = ["text", "code", "errorCode", "duration", "number", "timestamp"];
const LEFTOVER_TOKEN = /\{\{\s*[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?\s*\}\}/;
export function resolvePresentation(
presentation: PresentationTemplate | null | undefined,
matchedConditions: Record<string, unknown> | null | undefined,
explanation: string | null | undefined,
interpolate: Interpolate
): ResolvedPresentation | null {
if (!presentation || typeof presentation.role !== "string" || presentation.role === "") return null;
const resolve = (template: string | null | undefined): string | null => {
if (!template) return null;
const out = interpolate(template, matchedConditions).trim();
return out === "" || LEFTOVER_TOKEN.test(out) ? null : out;
};
const facts: ResolvedFact[] = [];
for (const fact of presentation.facts ?? []) {
if (!fact || !fact.label) continue;
const value = resolve(fact.value);
if (value === null) continue;
const format = fact.format && FACT_FORMATS.includes(fact.format) ? fact.format : "text";
facts.push({ label: fact.label, value, format });
}
let subject: ResolvedPresentation["subject"] = null;
const key = resolve(presentation.subject?.key);
if (presentation.subject && key !== null) {
subject = { kind: presentation.subject.kind, key, name: resolve(presentation.subject.name) ?? key };
}
const summary = resolve(presentation.summary) ?? resolve(firstSentence(explanation));
return { role: presentation.role, summary, subject, facts };
}
/** First sentence of the explanation's first paragraph, without markdown markers. */
function firstSentence(explanation: string | null | undefined): string | null {
if (!explanation) return null;
const paragraph = explanation.replace(/\r\n/g, "\n").split(/\n\s*\n/)[0] ?? "";
const plain = paragraph
.replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, "")
.replace(/\*\*|__|`/g, "")
.replace(/\s+/g, " ")
.trim();
const end = plain.search(/[.!?](?:\s|$)/);
return end === -1 ? plain : plain.slice(0, end + 1);
}
@@ -379,6 +379,8 @@ export interface AnalyzeRule {
remediation: RemediationStep[];
/** Links to relevant documentation */
relatedDocs: RelatedDoc[];
/** How a finding of this rule is shown (role, summary, subject, key facts); templates resolved when rendered. Optional: renderers fall back to the explanation without it. */
presentation?: RulePresentation | null;
/** Template variables that must be customized per-tenant before the rule can be used. If non-empty, the rule is a template: enabling it creates a tenant custom copy with the user's values substituted into the conditions. */
templateVariables: TemplateVariable[];
/** If this custom rule was created from a template, stores the original template rule's ID. Used to track lineage and prevent duplicate copies. */
@@ -3855,6 +3857,16 @@ export interface RuleDryRunPrecondition {
passed: boolean;
}
/** One key fact of a finding. */
export interface RuleFact {
/** Short label, for example "Install starts". */
label: string;
/** Template of the value, for example {{install_retry_loop.count}}. */
value: string;
/** How the value is shown (RuleFactFormats); text when absent. */
format?: string;
}
/** A device-fact gate evaluated before a rule's conditions. Pure boolean filter — does not contribute to evidence or confidence. When any precondition on a rule fails, the rule is silently skipped (no result emitted). Currently only event_data source is supported. */
export interface RulePrecondition {
/** Source of the fact. Currently only event_data. */
@@ -3871,6 +3883,18 @@ export interface RulePrecondition {
description?: string;
}
/** How a finding of an analyze rule is shown: its role, a one-line summary, what it is about and its key facts. Every string is a {{token}} template; like the explanation it is stored raw and resolved against the finding's evidence where it is rendered. A fact whose value was not recorded is left out. */
export interface RulePresentation {
/** What the finding means for the session outcome (RulePresentationRoles). */
role: string;
/** One-line template for the finding row; the first sentence of the explanation stands in when a token was not recorded. */
summary?: string;
/** What the finding is about, so findings about the same app or policy group together. */
subject?: RuleSubject;
/** Key facts shown as label/value pairs, in order (at most six). */
facts?: RuleFact[];
}
/** One ACTIVE rule-frequency regression (F3, insights spec §F3): an analyze rule whose 7-day hit rate rose ≥2× over its 28-day baseline with disjoint Wilson intervals. Persisted as the ruleregression|{ruleId} keyspace of the notification tracker table — the row IS the dedup (one bell per episode), the badge state (rules page) and the regressions[] payload (rule-stats response). Deleted when the rate re-arms (falls under 1.5× baseline or stops firing) or by the tracker's 30-day retention sweep (spec: retention cleanup re-arms). Numbers are refreshed on every radar pass while the episode stays active; FirstNotifiedAt never moves. */
export interface RuleRegressionAlert {
tenantId: string;
@@ -3920,6 +3944,8 @@ export interface RuleResult {
ruleId: string;
/** Human-readable title of the rule */
ruleTitle: string;
/** Version of the rule that produced this result. Null on rows written before the field existed. */
ruleVersion?: string;
/** Severity level: "info", "warning", "high", "critical" */
severity: string;
/** Rule category: network, identity, enrollment, apps, esp, device */
@@ -3932,6 +3958,8 @@ export interface RuleResult {
remediation: RemediationStep[];
/** Links to relevant documentation */
relatedDocs: RelatedDoc[];
/** The rule's presentation (role, summary, subject, key facts) as raw templates, resolved against MatchedConditions where rendered. Rows stored without one get the current rule's presentation when read; null when the rule has none. */
presentation?: RulePresentation;
/** Evidence: which conditions matched and their values */
matchedConditions: Record<string, unknown>;
/** When this issue was detected */
@@ -3991,6 +4019,16 @@ export interface RuleStatsSummary {
period: RuleStatsPeriod;
}
/** What a finding is about. Findings with the same kind and resolved key are grouped. Key and name use qualified tokens of one signal ({{failed_app.appId}}, {{failed_app.appName}}) so both come from the same event. */
export interface RuleSubject {
/** The kind of thing the key identifies (RuleSubjectKinds). */
kind: string;
/** Template of the grouping key. */
key: string;
/** Template of the display name; the key is shown when it is missing. */
name?: string;
}
/** Success body of GET global/rule-submissions/{id}: the submission, the frozen rule typed by kind (exactly one of the two rule properties is set), live fire stats, the suggested reserved id and the repo file built from the assigned (or suggested) id. */
export interface RuleSubmissionDetailResponse {
success: boolean;
@@ -287,3 +287,15 @@ export const API_ERROR_CODES = [
"JobIdCollision",
] as const;
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
/** Evidence keys a matched analyze-rule condition writes, per condition source — what a {{signal.key}} token can resolve (C# RuleEvidenceKeys). */
export const ANALYZE_RULE_EVIDENCE_KEYS: Readonly<Record<string, readonly string[]>> = {
"event_type": ["eventId","sequence","timestamp","eventType","field","value","count","appId","appName","errorPatternId","errorCode","exitCode","status","timeoutHours"],
"event_data": ["eventId","sequence","timestamp","field","value","appId","appName","errorPatternId","errorCode","exitCode","status","timeoutHours"],
"event_data_array": ["eventId","sequence","timestamp","eventType","field","value","matchCount","matchedItems","matchedItemsTruncated"],
"event_count": ["eventId","sequence","timestamp","count","threshold","groupKey","appName"],
"phase_duration": ["eventId","sequence","phaseStartTimestamp","phaseEndEventId","phase","durationSeconds","durationFormatted"],
"app_install_duration": ["eventId","sequence","startEventId","startTimestamp","endTimestamp","eventType","appId","appName","durationSeconds","durationFormatted"],
"event_correlation": ["joinField","joinValue","eventA_eventId","eventA_eventType","eventA_timestamp","eventA_sequence","eventB_eventId","eventB_eventType","eventB_timestamp","eventB_sequence","timeDeltaSeconds","totalMatches","allMatches","matchesTruncated","eventA_appId","eventA_appName","eventA_errorPatternId","eventA_errorCode","eventA_exitCode","eventA_status","eventA_timeoutHours","eventB_appId","eventB_appName","eventB_errorPatternId","eventB_errorCode","eventB_exitCode","eventB_status","eventB_timeoutHours"],
"clock_skew": ["field","value","direction","eventId","sequence","timestamp","eventType","offsetSeconds","offsetMinutes","jumpSeconds","jumpMinutes","beforeOffsetMinutes","afterOffsetMinutes","sampleCount","batchCount","sentAtBatchCount"],
};
@@ -1,95 +1,35 @@
/**
* MCP-side mirror of the web's `lib/interpolateRuleTemplate.ts`. Replaces
* `{{token}}` placeholders in a rule's explanation/remediation text with values
* pulled from the rule result's `matchedConditions` evidence map, so MCP tool
* output ships the same human-readable text the web UI renders — never the raw
* `{{reason}}/{{appName}}/{{errorCode}}` placeholders.
* Rule text rendering for MCP tool output, so tools ship the same human-readable text the web UI
* renders — never the raw `{{reason}}/{{appName}}/{{errorCode}}` placeholders.
*
* 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` (`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.
* `interpolateRuleTemplate` and `RULE_TEMPLATE_AUTO_FIELDS` are the web module
* (`lib/interpolateRuleTemplate.ts`), copied into src/generated/ by the web's
* `npm run generate:mcp-logic`; resolution order and the shared cases live there. The same holds
* for `resolvePresentation` (rule-presentation.generated.ts). This file adds the MCP-only walk over
* a session-analysis payload.
*/
import { interpolateRuleTemplate } from './generated/interpolate-rule-template.generated.js';
import {
resolvePresentation,
type PresentationTemplate,
type ResolvedPresentation,
} from './generated/rule-presentation.generated.js';
/**
* 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 { interpolateRuleTemplate, RULE_TEMPLATE_AUTO_FIELDS } from './generated/interpolate-rule-template.generated.js';
export function interpolateRuleTemplate(
text: string | null | undefined,
matchedConditions: Record<string, unknown> | null | undefined
): string {
if (!text) return text ?? '';
if (!matchedConditions || typeof matchedConditions !== 'object') return text;
const byField = new Map<string, string>();
const byAutoField = new Map<string, string>();
const bySignal = new Map<string, string>();
for (const [signal, evidence] of Object.entries(matchedConditions)) {
if (!evidence || typeof evidence !== 'object') {
if (evidence != null) bySignal.set(signal, formatValue(evidence));
continue;
}
const e = evidence as Record<string, unknown>;
if (typeof e.field === 'string' && 'value' in e) {
byField.set(e.field, formatValue(e.value));
}
if ('value' in e) {
bySignal.set(signal, formatValue(e.value));
}
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));
}
}
return text.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (raw, token: string) => {
if (byField.has(token)) return byField.get(token)!;
if (byAutoField.has(token)) return byAutoField.get(token)!;
if (bySignal.has(token)) return bySignal.get(token)!;
return raw;
});
}
function formatValue(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
try {
return JSON.stringify(v);
} catch {
return String(v);
}
/** A result's presentation after {@link interpolateAnalysisResults}: its resolved form, or null. */
export function resolvedPresentationOf(result: object): ResolvedPresentation | null {
const p = (result as { presentation?: unknown }).presentation;
return p && typeof p === 'object' && Array.isArray((p as { facts?: unknown }).facts) ? (p as ResolvedPresentation) : null;
}
/**
* Interpolates a session-analysis payload in place: walks `analysis.results[]`
* and substitutes `{{token}}` placeholders in each result's `explanation` and
* `remediation[].title` / `remediation[].steps[]` using that result's own
* `matchedConditions`. Tolerant of partial/malformed shapes — anything that
* isn't a string/array is left untouched. Mutates and returns the same object
* `matchedConditions`, and replaces a `presentation` block by its resolved form
* (facts that were not recorded left out). Tolerant of partial/malformed shapes — anything
* that isn't a string/array is left untouched. Mutates and returns the same object
* (the caller's `analysisData` is a fresh, throwaway fetch result).
*/
export function interpolateAnalysisResults<T>(analysis: T): T {
@@ -102,6 +42,16 @@ export function interpolateAnalysisResults<T>(analysis: T): T {
const result = r as Record<string, unknown>;
const mc = result.matchedConditions as Record<string, unknown> | null | undefined;
// Before the explanation is rewritten: the summary falls back to its first sentence.
if (result.presentation && typeof result.presentation === 'object') {
result.presentation = resolvePresentation(
result.presentation as PresentationTemplate,
mc,
typeof result.explanation === 'string' ? result.explanation : null,
interpolateRuleTemplate,
);
}
if (typeof result.explanation === 'string') {
result.explanation = interpolateRuleTemplate(result.explanation, mc);
}
@@ -255,8 +255,18 @@ export const RULE_AUTHORING_GUIDE = {
'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 / 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.',
'stay visible as {{token}} so typos are noticeable. A qualified token {{signal.key}} reads one key of one ' +
'condition\'s evidence (counts, durations, thresholds, correlation fields such as {{pair.eventA_appName}}); ' +
'which keys a condition records depends on its source — validate_rule checks them. test_analyze_rule ' +
'returns the interpolated preview for a real session.',
presentation:
'Optional block that tells the portal and the AI how to show a finding: role (outcome = restates the session ' +
'result; cause = can explain a failure or stall; context = a circumstance like standby, a restart or battery; ' +
'advisory = hygiene or security without bearing on the outcome), summary (one line), subject (kind + key + ' +
'name, grouping findings about the same app/policy/account; use qualified tokens of ONE signal for key and ' +
'name) and up to 6 facts (label + value template + format text|code|errorCode|duration|number|timestamp). ' +
'All values are {{token}} templates resolved per finding; a fact whose value was not recorded is left out, ' +
'and the summary falls back to the explanation\'s first sentence.',
markSessionAsFailed:
'markSessionAsFailedDefault=true escalates a firing rule to a terminal Failed session ' +
'status (KO criterion). Use sparingly — only for findings that genuinely mean the ' +
@@ -304,6 +314,15 @@ export const RULE_AUTHORING_GUIDE = {
remediation: [
{ title: 'Check the app package', steps: ['Review the install command line', 'Test the package on a reference device'] },
],
presentation: {
role: 'cause',
summary: '{{app_retry_storm.appName}} failed {{app_retry_storm.count}} times',
subject: { kind: 'app', key: '{{app_retry_storm.groupKey}}', name: '{{app_retry_storm.appName}}' },
facts: [
{ label: 'Failed installs', value: '{{app_retry_storm.count}}', format: 'number' },
{ label: 'Last error', value: '{{errorCode}}', format: 'errorCode' },
],
},
tags: ['apps', 'retry'],
},
},
@@ -330,5 +349,6 @@ export const RULE_AUTHORING_GUIDE = {
'Duplicate condition signals — evidence is keyed by signal, the second overwrites the first.',
'Expecting gather events in sessions that ran BEFORE the gather rule was deployed — dry-run against a recent session.',
'Adding an interim evaluateOn trigger to a rule whose only suppression is a not_exists precondition on enrollment_complete — mid-run that gate passes trivially and the rule fires on healthy in-flight sessions.',
'A presentation subject built from bare tokens ({{appId}}, {{appName}}) — a bare token can resolve from a different matched event than its neighbour; use {{signal.key}} of one condition.',
],
} as const;
@@ -390,6 +390,9 @@ export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = {
},
"default": []
},
"presentation": {
"$ref": "#/$defs/rulePresentation"
},
"tags": {
"type": "array",
"description": "Tags for filtering and categorization.",
@@ -719,6 +722,116 @@ export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = {
],
"additionalProperties": false
},
"rulePresentation": {
"type": "object",
"description": "How a finding of this rule is shown: its role, a one-line summary, what it is about and its key facts. Every string is a {{token}} template resolved from the finding's evidence when it is rendered; a fact whose value was not recorded is left out.",
"properties": {
"role": {
"type": "string",
"enum": [
"outcome",
"cause",
"context",
"advisory"
],
"description": "outcome: restates the session outcome (shown in the verdict, not as a finding row). cause: can explain why a session failed or is stuck. context: a circumstance such as standby, a restart or battery power. advisory: hygiene or security without bearing on the outcome."
},
"summary": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"description": "One line for the finding row, for example \"{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times\". When a token was not recorded, the first sentence of the explanation is shown instead."
},
"subject": {
"$ref": "#/$defs/ruleSubject"
},
"facts": {
"type": "array",
"description": "Key facts shown as label/value pairs, in this order.",
"maxItems": 6,
"items": {
"$ref": "#/$defs/ruleFact"
}
}
},
"required": [
"role"
],
"additionalProperties": false
},
"ruleSubject": {
"type": "object",
"description": "What the finding is about. Findings with the same kind and resolved key are grouped, for example every finding about one app. Use qualified tokens of one signal for key and name, such as {{failed_app.appId}} and {{failed_app.appName}}, so both come from the same event.",
"properties": {
"kind": {
"type": "string",
"enum": [
"app",
"policy",
"account",
"script",
"device",
"network",
"esp",
"session"
],
"description": "The kind of thing the key identifies."
},
"key": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Template of the grouping key."
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Template of the display name; the key is shown when it is missing."
}
},
"required": [
"kind",
"key"
],
"additionalProperties": false
},
"ruleFact": {
"type": "object",
"description": "One key fact of a finding.",
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"description": "Short label, for example \"Install starts\"."
},
"value": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Template of the value, for example {{install_retry_loop.count}}."
},
"format": {
"type": "string",
"enum": [
"text",
"code",
"errorCode",
"duration",
"number",
"timestamp"
],
"description": "How the value is shown. errorCode adds the error-code catalog meaning, duration reads seconds, timestamp an ISO 8601 time.",
"default": "text"
}
},
"required": [
"label",
"value"
],
"additionalProperties": false
},
"templateVariable": {
"type": "object",
"description": "A variable in a rule condition that must be customized per-tenant before the rule can be used.",
@@ -28,6 +28,7 @@ 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';
import { ANALYZE_RULE_EVIDENCE_KEYS } from './generated/wire-vocabularies.generated.js';
export type FindingLevel = 'error' | 'warning' | 'info';
@@ -311,20 +312,72 @@ function checkEventTypeKnown(eventType: string | undefined, where: string): Vali
}];
}
function extractTokens(rule: Record<string, unknown>): string[] {
const texts: string[] = [];
if (typeof rule.explanation === 'string') texts.push(rule.explanation);
/** Every template string of an analyze rule, with the field it sits in. */
function ruleTemplates(rule: Record<string, unknown>): Array<{ where: string; text: string }> {
const out: Array<{ where: string; text: string }> = [];
if (typeof rule.explanation === 'string') out.push({ where: 'explanation', text: rule.explanation });
if (Array.isArray(rule.remediation)) {
for (const step of rule.remediation as Array<{ title?: string; steps?: string[] }>) {
if (typeof step?.title === 'string') texts.push(step.title);
for (const s of step?.steps ?? []) if (typeof s === 'string') texts.push(s);
if (typeof step?.title === 'string') out.push({ where: 'remediation', text: step.title });
for (const s of step?.steps ?? []) if (typeof s === 'string') out.push({ where: 'remediation', text: s });
}
}
const tokens = new Set<string>();
for (const text of texts) {
for (const m of text.matchAll(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g)) tokens.add(m[1]);
const p = rule.presentation as
| { summary?: unknown; subject?: { key?: unknown; name?: unknown } | null; facts?: Array<{ value?: unknown }> | null }
| null
| undefined;
if (p && typeof p === 'object') {
if (typeof p.summary === 'string') out.push({ where: 'presentation.summary', text: p.summary });
if (typeof p.subject?.key === 'string') out.push({ where: 'presentation.subject.key', text: p.subject.key });
if (typeof p.subject?.name === 'string') out.push({ where: 'presentation.subject.name', text: p.subject.name });
(Array.isArray(p.facts) ? p.facts : []).forEach((f, i) => {
if (typeof f?.value === 'string') out.push({ where: `presentation.facts[${i}].value`, text: f.value });
});
}
return [...tokens];
return out;
}
/** Tokens per template: `token` alone, or `token` + `key` for a qualified `{{signal.key}}`. */
function extractTokenUses(rule: Record<string, unknown>): Array<{ where: string; token: string; key?: string }> {
const uses: Array<{ where: string; token: string; key?: string }> = [];
for (const { where, text } of ruleTemplates(rule)) {
for (const m of text.matchAll(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_]+))?\s*\}\}/g)) {
uses.push({ where, token: m[1], ...(m[2] !== undefined ? { key: m[2] } : {}) });
}
}
return uses;
}
/**
* Presentation advice beyond the schema: without the block the portal shows the explanation only;
* subject key and name must come from one evidence entry, which only qualified tokens of a single
* signal guarantee (a bare token may resolve from a different event than its neighbour).
*/
function lintPresentation(
rule: Record<string, unknown>,
uses: Array<{ where: string; token: string; key?: string }>,
): ValidationFinding[] {
if (!rule.presentation || typeof rule.presentation !== 'object') {
return [{
level: 'info',
message: 'No presentation block: the portal shows the explanation only. Add presentation.role, a summary and key facts so the finding can be grouped and read at a glance.',
}];
}
const subjectUses = uses.filter((u) => u.where.startsWith('presentation.subject.'));
const findings: ValidationFinding[] = [];
if (subjectUses.some((u) => u.key === undefined)) {
findings.push({
level: 'warning',
message: 'presentation.subject uses a bare token. Use qualified tokens of one signal, such as {{failed_app.appId}} and {{failed_app.appName}}, so key and name come from the same event.',
});
}
if (new Set(subjectUses.filter((u) => u.key !== undefined).map((u) => u.token)).size > 1) {
findings.push({
level: 'warning',
message: 'presentation.subject key and name read different signals — they may describe different events.',
});
}
return findings;
}
function lintAnalyzeRule(rule: Record<string, unknown>): ValidationFinding[] {
@@ -468,15 +521,38 @@ function lintAnalyzeRule(rule: Record<string, unknown>): ValidationFinding[] {
// so {{skewSummary}} interpolates at runtime (see EvaluateClockSkewCondition).
if (c.source === 'clock_skew') resolvable.add('skewSummary');
}
for (const token of extractTokens(rule)) {
if (!resolvable.has(token)) {
// A qualified {{signal.key}} resolves only from that condition's evidence; which keys an
// evidence entry carries depends on the condition source (C# RuleEvidenceKeys, pinned against
// the evaluators). Reported once per token.
const conditionBySignal = new Map(conditions.filter((c) => c.signal).map((c) => [c.signal!, c]));
const reported = new Set<string>();
for (const use of extractTokenUses(rule)) {
const raw = use.key === undefined ? use.token : `${use.token}.${use.key}`;
if (reported.has(raw)) continue;
let problem: string | null = null;
if (use.key === undefined) {
if (!resolvable.has(use.token)) {
problem = `no condition dataField/signal or auto-captured field (${AUTO_FIELDS.join(', ')}) resolves it`;
}
} else {
const condition = conditionBySignal.get(use.token);
const keys = condition ? ANALYZE_RULE_EVIDENCE_KEYS[condition.source ?? ''] ?? [] : [];
if (!condition) problem = `no condition has the signal "${use.token}"`;
else if (!keys.includes(use.key)) {
problem = `a ${condition.source} condition never records "${use.key}" (it records: ${keys.join(', ')})`;
}
}
if (problem) {
reported.add(raw);
findings.push({
level: 'warning',
message: `explanation/remediation references {{${token}}} but no condition dataField/signal or auto-captured field (${AUTO_FIELDS.join(', ')}) resolves it — it will render literally as {{${token}}}.`,
message: `${use.where} references {{${raw}}} but ${problem} — it will render literally as {{${raw}}}.`,
});
}
}
findings.push(...lintPresentation(rule, extractTokenUses(rule)));
if (rule.markSessionAsFailedDefault === true) {
findings.push({
level: 'info',
@@ -6,6 +6,7 @@ import { READ_ONLY, MAX_RESULT_SIZE_CHARS, toolResultText, SessionIdSchema } fro
import { toolError } from './error-handler.js';
import { validateRuleDraft } from '../rule-validation.js';
import { interpolateRuleTemplate } from '../interpolate-rule-template.js';
import { resolvePresentation, type PresentationTemplate } from '../generated/rule-presentation.generated.js';
import type {
AnalyzeRule,
DryRunAnalyzeRuleRequest,
@@ -116,7 +117,8 @@ export function registerRuleTools(server: McpServer, ga: boolean, ruleDryRun: bo
'verdict (fired / required_condition_not_met / below_confidence_threshold / skipped_by_precondition / ' +
'no_conditions_matched / no_events), per-condition matched/evidence with matchingEventCount (how many session ' +
'events have that eventType at all — the first thing to check when a condition unexpectedly misses), the ' +
'confidence breakdown per factor, and the interpolated explanation preview. NOTHING is persisted — safe to ' +
'confidence breakdown per factor, and the interpolated explanation and presentation preview (facts the session ' +
'did not record listed). NOTHING is persisted — safe to ' +
'iterate. Use a recent session (older sessions predate newly deployed gather rules). Test both directions: a ' +
'session where the rule should fire AND one where it should stay silent.' +
(ga ? ' Any tenant\'s session can be targeted; the tenant is resolved from the session.' : ''),
@@ -150,8 +152,25 @@ export function registerRuleTools(server: McpServer, ga: boolean, ruleDryRun: bo
}))
: undefined;
// The presentation exactly as the portal resolves it; facts this session did not record
// are named so the author can tell a wrong token from a missing value.
const draftPresentation =
rule.presentation && typeof rule.presentation === 'object' ? (rule.presentation as PresentationTemplate) : null;
const presentationPreview = draftPresentation
? resolvePresentation(draftPresentation, mc, typeof rule.explanation === 'string' ? rule.explanation : null, interpolateRuleTemplate)
: undefined;
const factsNotRecorded = (draftPresentation?.facts ?? [])
.filter((f) => f?.label && !presentationPreview?.facts.some((rf) => rf.label === f.label))
.map((f) => f.label);
return toolResultText(
{ ...data, interpolatedExplanation, interpolatedRemediation },
{
...data,
interpolatedExplanation,
interpolatedRemediation,
...(presentationPreview !== undefined ? { presentationPreview } : {}),
...(factsNotRecorded.length > 0 ? { presentationFactsNotRecorded: factsNotRecorded } : {}),
},
MAX_RESULT_SIZE_CHARS.small,
);
} catch (error: unknown) {
@@ -6,7 +6,7 @@ import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSIO
import { toolError } from './error-handler.js';
import { lookupErrorCode } from '../error-code-catalog.js';
import { withEventTypeNote, assertKnownDevicePropertyKeys } from '../resource-catalog.js';
import { interpolateAnalysisResults } from '../interpolate-rule-template.js';
import { interpolateAnalysisResults, resolvedPresentationOf } from '../interpolate-rule-template.js';
import { buildSessionCoverage } from '../session-coverage.js';
import { API_BASE_URL } from '../config.js';
import type {
@@ -456,7 +456,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
'diagnostics-package health, and coverage.gaps — one line per known blind spot; read it before treating a ' +
'missing event as proof that something did not happen); a key-event timeline (errors, warnings, phase ' +
'transitions, app installs; noise filtered; capped at the 50 most relevant, stats.keyEventsTruncated says if ' +
'more were dropped); rule analysis (probable cause, remediation); aggregate stats. A key event with an error ' +
'more were dropped); rule analysis (per finding: ruleId, role, summary, key facts, explanation, remediation); ' +
'aggregate stats. A key event with an error ' +
'code shows errorCode plus errorText (symbol and catalog meaning); event payloads (data) are otherwise not ' +
'included — get_session_events returns them. get_session returns the full metadata.',
inputSchema: {
@@ -627,13 +628,22 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
highCount: analysisData.highCount ?? 0,
warningCount: analysisData.warningCount ?? 0,
...(resolvedCount > 0 ? { resolvedCount } : {}),
issues: results.map((r) => ({
ruleTitle: r.ruleTitle,
severity: r.severity,
...(r.isInterim ? { isInterim: true } : {}),
explanation: r.explanation,
remediation: r.remediation,
})),
issues: results.map((r) => {
// Resolved by interpolateAnalysisResults: facts that were not recorded are already gone.
const p = resolvedPresentationOf(r);
return {
ruleId: r.ruleId,
ruleTitle: r.ruleTitle,
severity: r.severity,
...(r.isInterim ? { isInterim: true } : {}),
...(p ? { role: p.role } : {}),
...(p?.summary ? { summary: p.summary } : {}),
...(p?.subject ? { subject: p.subject } : {}),
...(p && p.facts.length > 0 ? { facts: p.facts } : {}),
explanation: r.explanation,
remediation: r.remediation,
};
}),
};
}
@@ -137,6 +137,12 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public List<RelatedDoc> RelatedDocs { get; set; } = new List<RelatedDoc>();
/// <summary>
/// How a finding of this rule is shown (role, summary, subject, key facts); templates resolved
/// when rendered. Optional: renderers fall back to the explanation without it.
/// </summary>
public RulePresentation? Presentation { get; set; }
// ===== TEMPLATE =====
/// <summary>
@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// The keys a matched analyze-rule condition writes into its evidence entry, per condition
/// source — what a qualified template token <c>{{signal.key}}</c> can read. Some keys come from
/// one branch only (<c>groupKey</c> of a per-group count, <c>jumpSeconds</c> of a clock jump), so
/// a token on them resolves only when the rule uses that branch. The rule engine's evaluators are
/// pinned against this catalog by tests; the MCP rule lint and the presentation ratchet read it
/// from the shared manifest instead of assuming that every key resolves.
/// </summary>
public static class RuleEvidenceKeys
{
/// <summary>
/// Same-event identifiers copied into event_type and event_data evidence and, prefixed
/// <c>eventA_</c>/<c>eventB_</c>, into correlation pairs. Short identifiers only; free text
/// such as <c>errorDetail</c> or <c>message</c> stays out (Table Storage's 64 KB limit). Every
/// renderer resolves these as bare <c>{{token}}</c> too.
/// </summary>
public static readonly IReadOnlyList<string> AutoFields = new[]
{
"appId", "appName", "errorPatternId", "errorCode", "exitCode", "status", "timeoutHours",
};
/// <summary>Evidence keys by condition source (the <c>source</c> of a rule condition).</summary>
public static readonly IReadOnlyDictionary<string, IReadOnlyList<string>> BySource =
new Dictionary<string, IReadOnlyList<string>>
{
["event_type"] = Keys(AutoFields, "eventId", "sequence", "timestamp", "eventType", "field", "value", "count"),
["event_data"] = Keys(AutoFields, "eventId", "sequence", "timestamp", "field", "value"),
["event_data_array"] = Keys(Array.Empty<string>(),
"eventId", "sequence", "timestamp", "eventType", "field", "value",
"matchCount", "matchedItems", "matchedItemsTruncated"),
["event_count"] = Keys(Array.Empty<string>(), "eventId", "sequence", "timestamp", "count", "threshold", "groupKey", "appName"),
["phase_duration"] = Keys(Array.Empty<string>(),
"eventId", "sequence", "phaseStartTimestamp", "phaseEndEventId", "phase",
"durationSeconds", "durationFormatted"),
["app_install_duration"] = Keys(Array.Empty<string>(),
"eventId", "sequence", "startEventId", "startTimestamp", "endTimestamp", "eventType",
"appId", "appName", "durationSeconds", "durationFormatted"),
["event_correlation"] = Keys(
AutoFields.Select(f => "eventA_" + f).Concat(AutoFields.Select(f => "eventB_" + f)),
"joinField", "joinValue",
"eventA_eventId", "eventA_eventType", "eventA_timestamp", "eventA_sequence",
"eventB_eventId", "eventB_eventType", "eventB_timestamp", "eventB_sequence",
"timeDeltaSeconds", "totalMatches", "allMatches", "matchesTruncated"),
["clock_skew"] = Keys(Array.Empty<string>(),
"field", "value", "direction", "eventId", "sequence", "timestamp", "eventType",
"offsetSeconds", "offsetMinutes", "jumpSeconds", "jumpMinutes",
"beforeOffsetMinutes", "afterOffsetMinutes", "sampleCount", "batchCount", "sentAtBatchCount"),
};
private static IReadOnlyList<string> Keys(IEnumerable<string> extra, params string[] keys)
=> keys.Concat(extra).ToArray();
}
}
@@ -0,0 +1,146 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// How a finding of an analyze rule is shown: its role, a one-line summary, what it is about and
/// its key facts. Every string is a <c>{{token}}</c> template; like the explanation it is stored
/// raw and resolved against the finding's evidence where it is rendered. A fact whose value was
/// not recorded is left out.
/// </summary>
public class RulePresentation
{
/// <summary>What the finding means for the session outcome (<see cref="RulePresentationRoles"/>).</summary>
public string Role { get; set; } = default!;
/// <summary>One-line template for the finding row; the first sentence of the explanation stands in when a token was not recorded.</summary>
public string? Summary { get; set; }
/// <summary>What the finding is about, so findings about the same app or policy group together.</summary>
public RuleSubject? Subject { get; set; }
/// <summary>Key facts shown as label/value pairs, in order (at most six).</summary>
public List<RuleFact>? Facts { get; set; }
}
/// <summary>
/// What a finding is about. Findings with the same kind and resolved key are grouped. Key and name
/// use qualified tokens of one signal (<c>{{failed_app.appId}}</c>, <c>{{failed_app.appName}}</c>)
/// so both come from the same event.
/// </summary>
public class RuleSubject
{
/// <summary>The kind of thing the key identifies (<see cref="RuleSubjectKinds"/>).</summary>
public string Kind { get; set; } = default!;
/// <summary>Template of the grouping key.</summary>
public string Key { get; set; } = default!;
/// <summary>Template of the display name; the key is shown when it is missing.</summary>
public string? Name { get; set; }
}
/// <summary>One key fact of a finding.</summary>
public class RuleFact
{
/// <summary>Short label, for example "Install starts".</summary>
public string Label { get; set; } = default!;
/// <summary>Template of the value, for example <c>{{install_retry_loop.count}}</c>.</summary>
public string Value { get; set; } = default!;
/// <summary>How the value is shown (<see cref="RuleFactFormats"/>); text when absent.</summary>
public string? Format { get; set; }
}
/// <summary>Roles of a finding (<see cref="RulePresentation.Role"/>); the schema enum mirrors <see cref="All"/>.</summary>
public static class RulePresentationRoles
{
/// <summary>Restates the session outcome; shown in the verdict, not as a finding row.</summary>
public const string Outcome = "outcome";
/// <summary>Can explain why a session failed or is stuck.</summary>
public const string Cause = "cause";
/// <summary>A circumstance such as standby, a restart or battery power.</summary>
public const string Context = "context";
/// <summary>Hygiene or security without bearing on the outcome.</summary>
public const string Advisory = "advisory";
/// <summary>Every role, declaration order.</summary>
public static readonly IReadOnlyList<string> All = new[] { Outcome, Cause, Context, Advisory };
}
/// <summary>Subject kinds (<see cref="RuleSubject.Kind"/>); the schema enum mirrors <see cref="All"/>.</summary>
public static class RuleSubjectKinds
{
/// <summary>An app or package.</summary>
public const string App = "app";
/// <summary>A configuration or MDM policy.</summary>
public const string Policy = "policy";
/// <summary>A user or local account.</summary>
public const string Account = "account";
/// <summary>A platform, remediation or health script.</summary>
public const string Script = "script";
/// <summary>The device itself (power, clock, hardware, firmware).</summary>
public const string Device = "device";
/// <summary>Network, proxy or connectivity.</summary>
public const string Network = "network";
/// <summary>An Enrollment Status Page phase or step.</summary>
public const string Esp = "esp";
/// <summary>The enrollment session as a whole.</summary>
public const string Session = "session";
/// <summary>Every subject kind, declaration order.</summary>
public static readonly IReadOnlyList<string> All = new[] { App, Policy, Account, Script, Device, Network, Esp, Session };
}
/// <summary>Fact formats (<see cref="RuleFact.Format"/>); the schema enum mirrors <see cref="All"/>.</summary>
public static class RuleFactFormats
{
/// <summary>Plain text (the default).</summary>
public const string Text = "text";
/// <summary>Monospaced, for identifiers and paths.</summary>
public const string Code = "code";
/// <summary>An error code; renderers add the error-code catalog meaning.</summary>
public const string ErrorCode = "errorCode";
/// <summary>A duration in seconds.</summary>
public const string Duration = "duration";
/// <summary>A number.</summary>
public const string Number = "number";
/// <summary>An ISO 8601 point in time.</summary>
public const string Timestamp = "timestamp";
/// <summary>Every format, declaration order.</summary>
public static readonly IReadOnlyList<string> All = new[] { Text, Code, ErrorCode, Duration, Number, Timestamp };
}
/// <summary>Limits of a presentation block; the schema carries the same numbers.</summary>
public static class RulePresentationLimits
{
/// <summary>Maximum length of the summary template.</summary>
public const int SummaryMaxLength = 160;
/// <summary>Maximum number of facts.</summary>
public const int MaxFacts = 6;
/// <summary>Maximum length of a fact label.</summary>
public const int LabelMaxLength = 40;
/// <summary>Maximum length of a fact value, subject key or subject name template.</summary>
public const int TemplateMaxLength = 200;
}
}
@@ -34,6 +34,11 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string RuleTitle { get; set; } = default!;
/// <summary>
/// Version of the rule that produced this result. Null on rows written before the field existed.
/// </summary>
public string? RuleVersion { get; set; }
/// <summary>
/// Severity level: "info", "warning", "high", "critical"
/// </summary>
@@ -65,6 +70,13 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public List<RelatedDoc> RelatedDocs { get; set; } = new List<RelatedDoc>();
/// <summary>
/// The rule's presentation (role, summary, subject, key facts) as raw templates, resolved
/// against <see cref="MatchedConditions"/> where rendered. Rows stored without one get the
/// current rule's presentation when read; null when the rule has none.
/// </summary>
public RulePresentation? Presentation { get; set; }
/// <summary>
/// Evidence: which conditions matched and their values
/// </summary>
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
/**
* The MCP server runs copies of some web modules (scripts/generate-mcp-shared-logic.js). A web
* change that skips `npm run generate:mcp-logic` fails here, not only in the MCP suite.
*/
const require = createRequire(import.meta.url);
const { MODULES, buildMcpLogicCopy } = require("../../scripts/generate-mcp-shared-logic.js") as {
MODULES: { source: string; dest: string }[];
buildMcpLogicCopy: (source: string, text: string) => string;
};
const webRoot = path.resolve(__dirname, "..", "..");
const mcpGenerated = path.resolve(webRoot, "..", "..", "McpServer", "autopilot-monitor-mcp", "src", "generated");
describe("MCP copies of shared web logic", () => {
it.each(MODULES.map((m) => [m.source, m] as const))("%s is fresh in the MCP", (_source, m) => {
const sourceText = fs.readFileSync(path.join(webRoot, m.source), "utf8");
const committed = fs.readFileSync(path.join(mcpGenerated, m.dest), "utf8").replace(/\r\n/g, "\n");
expect(committed).toBe(buildMcpLogicCopy(m.source, sourceText));
});
});
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { resolvePresentation, type PresentationTemplate } from "../rulePresentation";
import { interpolateRuleTemplate } from "../interpolateRuleTemplate";
interface PresentationCase {
name: string;
presentation: PresentationTemplate | null;
matchedConditions: Record<string, unknown>;
explanation: string;
expected: unknown;
}
/** tests/fixtures/rule-presentation at the repo root — the cases web and MCP both run. */
function loadCases(): PresentationCase[] {
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;
}
const file = path.join(dir, "tests", "fixtures", "rule-presentation", "cases.json");
return (JSON.parse(fs.readFileSync(file, "utf8")) as { cases: PresentationCase[] }).cases;
}
describe("resolvePresentation — shared cases (web = MCP)", () => {
const cases = loadCases();
it("has cases", () => {
expect(cases.length).toBeGreaterThanOrEqual(8);
});
it.each(cases.map((c) => [c.name, c] as const))("%s", (_name, c) => {
expect(resolvePresentation(c.presentation, c.matchedConditions, c.explanation, interpolateRuleTemplate)).toEqual(
c.expected
);
});
});
@@ -809,6 +809,127 @@ export const SHARED_MANIFEST = {
"not_regex",
"regex"
],
"analyzeRuleEvidenceKeys": {
"event_type": [
"eventId",
"sequence",
"timestamp",
"eventType",
"field",
"value",
"count",
"appId",
"appName",
"errorPatternId",
"errorCode",
"exitCode",
"status",
"timeoutHours"
],
"event_data": [
"eventId",
"sequence",
"timestamp",
"field",
"value",
"appId",
"appName",
"errorPatternId",
"errorCode",
"exitCode",
"status",
"timeoutHours"
],
"event_data_array": [
"eventId",
"sequence",
"timestamp",
"eventType",
"field",
"value",
"matchCount",
"matchedItems",
"matchedItemsTruncated"
],
"event_count": [
"eventId",
"sequence",
"timestamp",
"count",
"threshold",
"groupKey",
"appName"
],
"phase_duration": [
"eventId",
"sequence",
"phaseStartTimestamp",
"phaseEndEventId",
"phase",
"durationSeconds",
"durationFormatted"
],
"app_install_duration": [
"eventId",
"sequence",
"startEventId",
"startTimestamp",
"endTimestamp",
"eventType",
"appId",
"appName",
"durationSeconds",
"durationFormatted"
],
"event_correlation": [
"joinField",
"joinValue",
"eventA_eventId",
"eventA_eventType",
"eventA_timestamp",
"eventA_sequence",
"eventB_eventId",
"eventB_eventType",
"eventB_timestamp",
"eventB_sequence",
"timeDeltaSeconds",
"totalMatches",
"allMatches",
"matchesTruncated",
"eventA_appId",
"eventA_appName",
"eventA_errorPatternId",
"eventA_errorCode",
"eventA_exitCode",
"eventA_status",
"eventA_timeoutHours",
"eventB_appId",
"eventB_appName",
"eventB_errorPatternId",
"eventB_errorCode",
"eventB_exitCode",
"eventB_status",
"eventB_timeoutHours"
],
"clock_skew": [
"field",
"value",
"direction",
"eventId",
"sequence",
"timestamp",
"eventType",
"offsetSeconds",
"offsetMinutes",
"jumpSeconds",
"jumpMinutes",
"beforeOffsetMinutes",
"afterOffsetMinutes",
"sampleCount",
"batchCount",
"sentAtBatchCount"
]
},
"eventTypes": [
"aad_join_status",
"aad_placeholder_user_detected",
@@ -805,6 +805,127 @@
"not_regex",
"regex"
],
"analyzeRuleEvidenceKeys": {
"event_type": [
"eventId",
"sequence",
"timestamp",
"eventType",
"field",
"value",
"count",
"appId",
"appName",
"errorPatternId",
"errorCode",
"exitCode",
"status",
"timeoutHours"
],
"event_data": [
"eventId",
"sequence",
"timestamp",
"field",
"value",
"appId",
"appName",
"errorPatternId",
"errorCode",
"exitCode",
"status",
"timeoutHours"
],
"event_data_array": [
"eventId",
"sequence",
"timestamp",
"eventType",
"field",
"value",
"matchCount",
"matchedItems",
"matchedItemsTruncated"
],
"event_count": [
"eventId",
"sequence",
"timestamp",
"count",
"threshold",
"groupKey",
"appName"
],
"phase_duration": [
"eventId",
"sequence",
"phaseStartTimestamp",
"phaseEndEventId",
"phase",
"durationSeconds",
"durationFormatted"
],
"app_install_duration": [
"eventId",
"sequence",
"startEventId",
"startTimestamp",
"endTimestamp",
"eventType",
"appId",
"appName",
"durationSeconds",
"durationFormatted"
],
"event_correlation": [
"joinField",
"joinValue",
"eventA_eventId",
"eventA_eventType",
"eventA_timestamp",
"eventA_sequence",
"eventB_eventId",
"eventB_eventType",
"eventB_timestamp",
"eventB_sequence",
"timeDeltaSeconds",
"totalMatches",
"allMatches",
"matchesTruncated",
"eventA_appId",
"eventA_appName",
"eventA_errorPatternId",
"eventA_errorCode",
"eventA_exitCode",
"eventA_status",
"eventA_timeoutHours",
"eventB_appId",
"eventB_appName",
"eventB_errorPatternId",
"eventB_errorCode",
"eventB_exitCode",
"eventB_status",
"eventB_timeoutHours"
],
"clock_skew": [
"field",
"value",
"direction",
"eventId",
"sequence",
"timestamp",
"eventType",
"offsetSeconds",
"offsetMinutes",
"jumpSeconds",
"jumpMinutes",
"beforeOffsetMinutes",
"afterOffsetMinutes",
"sampleCount",
"batchCount",
"sentAtBatchCount"
]
},
"eventTypes": [
"aad_join_status",
"aad_placeholder_user_detected",
@@ -2570,6 +2691,16 @@
},
"doc": "Links to relevant documentation"
},
{
"name": "presentation",
"type": {
"kind": "ref",
"name": "RulePresentation",
"nullable": true
},
"optional": true,
"doc": "How a finding of this rule is shown (role, summary, subject, key facts); templates resolved when rendered. Optional: renderers fall back to the explanation without it."
},
{
"name": "templateVariables",
"type": {
@@ -17792,6 +17923,37 @@
}
]
},
"RuleFact": {
"kind": "object",
"doc": "One key fact of a finding.",
"fields": [
{
"name": "label",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Short label, for example \"Install starts\"."
},
{
"name": "value",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Template of the value, for example {{install_retry_loop.count}}."
},
{
"name": "format",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "How the value is shown (RuleFactFormats); text when absent."
}
]
},
"RulePrecondition": {
"kind": "object",
"doc": "A device-fact gate evaluated before a rule's conditions. Pure boolean filter — does not contribute to evidence or confidence. When any precondition on a rule fails, the rule is silently skipped (no result emitted). Currently only event_data source is supported.",
@@ -17847,6 +18009,50 @@
}
]
},
"RulePresentation": {
"kind": "object",
"doc": "How a finding of an analyze rule is shown: its role, a one-line summary, what it is about and its key facts. Every string is a {{token}} template; like the explanation it is stored raw and resolved against the finding's evidence where it is rendered. A fact whose value was not recorded is left out.",
"fields": [
{
"name": "role",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "What the finding means for the session outcome (RulePresentationRoles)."
},
{
"name": "summary",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "One-line template for the finding row; the first sentence of the explanation stands in when a token was not recorded."
},
{
"name": "subject",
"type": {
"kind": "ref",
"name": "RuleSubject"
},
"optional": true,
"doc": "What the finding is about, so findings about the same app or policy group together."
},
{
"name": "facts",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "RuleFact"
}
},
"optional": true,
"doc": "Key facts shown as label/value pairs, in order (at most six)."
}
]
},
"RuleRegressionAlert": {
"kind": "object",
"doc": "One ACTIVE rule-frequency regression (F3, insights spec §F3): an analyze rule whose 7-day hit rate rose ≥2× over its 28-day baseline with disjoint Wilson intervals. Persisted as the ruleregression|{ruleId} keyspace of the notification tracker table — the row IS the dedup (one bell per episode), the badge state (rules page) and the regressions[] payload (rule-stats response). Deleted when the rate re-arms (falls under 1.5× baseline or stops firing) or by the tracker's 30-day retention sweep (spec: retention cleanup re-arms). Numbers are refreshed on every radar pass while the episode stays active; FirstNotifiedAt never moves.",
@@ -18060,6 +18266,15 @@
},
"doc": "Human-readable title of the rule"
},
{
"name": "ruleVersion",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Version of the rule that produced this result. Null on rows written before the field existed."
},
{
"name": "severity",
"type": {
@@ -18114,6 +18329,15 @@
},
"doc": "Links to relevant documentation"
},
{
"name": "presentation",
"type": {
"kind": "ref",
"name": "RulePresentation"
},
"optional": true,
"doc": "The rule's presentation (role, summary, subject, key facts) as raw templates, resolved against MatchedConditions where rendered. Rows stored without one get the current rule's presentation when read; null when the rule has none."
},
{
"name": "matchedConditions",
"type": {
@@ -18373,6 +18597,37 @@
}
]
},
"RuleSubject": {
"kind": "object",
"doc": "What a finding is about. Findings with the same kind and resolved key are grouped. Key and name use qualified tokens of one signal ({{failed_app.appId}}, {{failed_app.appName}}) so both come from the same event.",
"fields": [
{
"name": "kind",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "The kind of thing the key identifies (RuleSubjectKinds)."
},
{
"name": "key",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Template of the grouping key."
},
{
"name": "name",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Template of the display name; the key is shown when it is missing."
}
]
},
"RuleSubmissionDetailResponse": {
"kind": "object",
"doc": "Success body of GET global/rule-submissions/{id}: the submission, the frozen rule typed by kind (exactly one of the two rule properties is set), live fire stats, the suggested reserved id and the repo file built from the assigned (or suggested) id.",
@@ -379,6 +379,8 @@ export interface AnalyzeRule {
remediation: RemediationStep[];
/** Links to relevant documentation */
relatedDocs: RelatedDoc[];
/** How a finding of this rule is shown (role, summary, subject, key facts); templates resolved when rendered. Optional: renderers fall back to the explanation without it. */
presentation?: RulePresentation | null;
/** Template variables that must be customized per-tenant before the rule can be used. If non-empty, the rule is a template: enabling it creates a tenant custom copy with the user's values substituted into the conditions. */
templateVariables: TemplateVariable[];
/** If this custom rule was created from a template, stores the original template rule's ID. Used to track lineage and prevent duplicate copies. */
@@ -3855,6 +3857,16 @@ export interface RuleDryRunPrecondition {
passed: boolean;
}
/** One key fact of a finding. */
export interface RuleFact {
/** Short label, for example "Install starts". */
label: string;
/** Template of the value, for example {{install_retry_loop.count}}. */
value: string;
/** How the value is shown (RuleFactFormats); text when absent. */
format?: string;
}
/** A device-fact gate evaluated before a rule's conditions. Pure boolean filter — does not contribute to evidence or confidence. When any precondition on a rule fails, the rule is silently skipped (no result emitted). Currently only event_data source is supported. */
export interface RulePrecondition {
/** Source of the fact. Currently only event_data. */
@@ -3871,6 +3883,18 @@ export interface RulePrecondition {
description?: string;
}
/** How a finding of an analyze rule is shown: its role, a one-line summary, what it is about and its key facts. Every string is a {{token}} template; like the explanation it is stored raw and resolved against the finding's evidence where it is rendered. A fact whose value was not recorded is left out. */
export interface RulePresentation {
/** What the finding means for the session outcome (RulePresentationRoles). */
role: string;
/** One-line template for the finding row; the first sentence of the explanation stands in when a token was not recorded. */
summary?: string;
/** What the finding is about, so findings about the same app or policy group together. */
subject?: RuleSubject;
/** Key facts shown as label/value pairs, in order (at most six). */
facts?: RuleFact[];
}
/** One ACTIVE rule-frequency regression (F3, insights spec §F3): an analyze rule whose 7-day hit rate rose ≥2× over its 28-day baseline with disjoint Wilson intervals. Persisted as the ruleregression|{ruleId} keyspace of the notification tracker table — the row IS the dedup (one bell per episode), the badge state (rules page) and the regressions[] payload (rule-stats response). Deleted when the rate re-arms (falls under 1.5× baseline or stops firing) or by the tracker's 30-day retention sweep (spec: retention cleanup re-arms). Numbers are refreshed on every radar pass while the episode stays active; FirstNotifiedAt never moves. */
export interface RuleRegressionAlert {
tenantId: string;
@@ -3920,6 +3944,8 @@ export interface RuleResult {
ruleId: string;
/** Human-readable title of the rule */
ruleTitle: string;
/** Version of the rule that produced this result. Null on rows written before the field existed. */
ruleVersion?: string;
/** Severity level: "info", "warning", "high", "critical" */
severity: string;
/** Rule category: network, identity, enrollment, apps, esp, device */
@@ -3932,6 +3958,8 @@ export interface RuleResult {
remediation: RemediationStep[];
/** Links to relevant documentation */
relatedDocs: RelatedDoc[];
/** The rule's presentation (role, summary, subject, key facts) as raw templates, resolved against MatchedConditions where rendered. Rows stored without one get the current rule's presentation when read; null when the rule has none. */
presentation?: RulePresentation;
/** Evidence: which conditions matched and their values */
matchedConditions: Record<string, unknown>;
/** When this issue was detected */
@@ -3991,6 +4019,16 @@ export interface RuleStatsSummary {
period: RuleStatsPeriod;
}
/** What a finding is about. Findings with the same kind and resolved key are grouped. Key and name use qualified tokens of one signal ({{failed_app.appId}}, {{failed_app.appName}}) so both come from the same event. */
export interface RuleSubject {
/** The kind of thing the key identifies (RuleSubjectKinds). */
kind: string;
/** Template of the grouping key. */
key: string;
/** Template of the display name; the key is shown when it is missing. */
name?: string;
}
/** Success body of GET global/rule-submissions/{id}: the submission, the frozen rule typed by kind (exactly one of the two rule properties is set), live fire stats, the suggested reserved id and the repo file built from the assigned (or suggested) id. */
export interface RuleSubmissionDetailResponse {
success: boolean;
@@ -16,7 +16,11 @@
* (e.g. {{ca2023_not_updated}} -> that signal's value).
* 4. Unresolved tokens are left untouched so authors notice the typo.
*
* Token chars: [a-zA-Z0-9_]. Whitespace inside the braces is tolerated.
* A qualified token `{{signal.key}}` reads `key` of the evidence entry named `signal` (counts,
* durations, correlation fields). It has no fallback: an unknown signal or key, a scalar
* entry or a null value leaves it literal.
*
* Token chars: [a-zA-Z0-9_], one optional `.key`. 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.
@@ -71,12 +75,26 @@ export function interpolateRuleTemplate(
}
}
return text.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (raw, token: string) => {
if (byField.has(token)) return byField.get(token)!;
if (byAutoField.has(token)) return byAutoField.get(token)!;
if (bySignal.has(token)) return bySignal.get(token)!;
return raw;
});
return text.replace(
/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_]+))?\s*\}\}/g,
(raw, token: string, key: string | undefined) => {
if (key !== undefined) return qualifiedValue(matchedConditions, token, key) ?? raw;
if (byField.has(token)) return byField.get(token)!;
if (byAutoField.has(token)) return byAutoField.get(token)!;
if (bySignal.has(token)) return bySignal.get(token)!;
return raw;
}
);
}
/** `{{signal.key}}`: own properties only, so `constructor`/`toString` never resolve. */
function qualifiedValue(matchedConditions: Record<string, unknown>, signal: string, key: string): string | null {
if (!Object.prototype.hasOwnProperty.call(matchedConditions, signal)) return null;
const entry = matchedConditions[signal];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
if (!Object.prototype.hasOwnProperty.call(entry, key)) return null;
const v = (entry as Record<string, unknown>)[key];
return v == null ? null : formatValue(v);
}
function formatValue(v: unknown): string {
@@ -0,0 +1,84 @@
/**
* Resolves an analyze rule's presentation block (role, summary, subject, key facts) against one
* finding's evidence. Like the explanation, a stored finding keeps the raw `{{token}}` templates
* and every renderer resolves them; this module is the one resolution of the presentation, pinned
* by `tests/fixtures/rule-presentation/cases.json` at the repo root:
* - a fact whose value does not resolve (token not recorded, or empty) is left out;
* - the summary falls back to the first sentence of the explanation;
* - a subject whose key does not resolve is no subject; a missing name shows the key;
* - an absent or unknown fact format reads as "text".
*
* Self-contained on purpose: the MCP server runs a generated copy (npm run generate:mcp-logic),
* so the interpolation is passed in instead of imported.
*/
export interface PresentationTemplate {
role: string;
summary?: string | null;
subject?: { kind: string; key: string; name?: string | null } | null;
facts?: { label: string; value: string; format?: string | null }[] | null;
}
export interface ResolvedFact {
label: string;
value: string;
format: string;
}
export interface ResolvedPresentation {
role: string;
summary: string | null;
subject: { kind: string; key: string; name: string } | null;
facts: ResolvedFact[];
}
type Interpolate = (text: string, matchedConditions: Record<string, unknown> | null | undefined) => string;
const FACT_FORMATS = ["text", "code", "errorCode", "duration", "number", "timestamp"];
const LEFTOVER_TOKEN = /\{\{\s*[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?\s*\}\}/;
export function resolvePresentation(
presentation: PresentationTemplate | null | undefined,
matchedConditions: Record<string, unknown> | null | undefined,
explanation: string | null | undefined,
interpolate: Interpolate
): ResolvedPresentation | null {
if (!presentation || typeof presentation.role !== "string" || presentation.role === "") return null;
const resolve = (template: string | null | undefined): string | null => {
if (!template) return null;
const out = interpolate(template, matchedConditions).trim();
return out === "" || LEFTOVER_TOKEN.test(out) ? null : out;
};
const facts: ResolvedFact[] = [];
for (const fact of presentation.facts ?? []) {
if (!fact || !fact.label) continue;
const value = resolve(fact.value);
if (value === null) continue;
const format = fact.format && FACT_FORMATS.includes(fact.format) ? fact.format : "text";
facts.push({ label: fact.label, value, format });
}
let subject: ResolvedPresentation["subject"] = null;
const key = resolve(presentation.subject?.key);
if (presentation.subject && key !== null) {
subject = { kind: presentation.subject.kind, key, name: resolve(presentation.subject.name) ?? key };
}
const summary = resolve(presentation.summary) ?? resolve(firstSentence(explanation));
return { role: presentation.role, summary, subject, facts };
}
/** First sentence of the explanation's first paragraph, without markdown markers. */
function firstSentence(explanation: string | null | undefined): string | null {
if (!explanation) return null;
const paragraph = explanation.replace(/\r\n/g, "\n").split(/\n\s*\n/)[0] ?? "";
const plain = paragraph
.replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, "")
.replace(/\*\*|__|`/g, "")
.replace(/\s+/g, " ")
.trim();
const end = plain.search(/[.!?](?:\s|$)/);
return end === -1 ? plain : plain.slice(0, end + 1);
}
@@ -11,6 +11,7 @@
"sync-error-codes": "node scripts/sync-error-codes.js",
"whats-new": "node scripts/whats-new/build-whats-new.js",
"generate:manifests": "node scripts/generate-shared-manifest-types.js",
"generate:mcp-logic": "node scripts/generate-mcp-shared-logic.js",
"build": "next build",
"start": "next start",
"lint": "eslint .",
@@ -0,0 +1,53 @@
/**
* Copy web logic modules that the MCP server must run identically into its src/generated/.
*
* The web file is the one source. The MCP copy is the same code under a GENERATED header, so the
* two cannot drift and do not count as a duplicate (the dup gate ignores generated/). A copied
* module must stay self-contained: no imports, because the MCP compiles it with its own config.
*
* Run: node scripts/generate-mcp-shared-logic.js (npm run generate:mcp-logic)
* Freshness is pinned twice: lib/__tests__/mcpSharedLogic.test.ts here and the MCP suite's
* shared-logic-freshness.test.ts.
*/
const fs = require("fs");
const path = require("path");
const WEB_ROOT = path.resolve(__dirname, "..");
const MCP_GENERATED = path.resolve(WEB_ROOT, "..", "..", "McpServer", "autopilot-monitor-mcp", "src", "generated");
/** Web source (relative to the web root) → file name under the MCP's src/generated/. */
const MODULES = [
{ source: "lib/interpolateRuleTemplate.ts", dest: "interpolate-rule-template.generated.ts" },
{ source: "lib/rulePresentation.ts", dest: "rule-presentation.generated.ts" },
];
/** Pure builder so both freshness tests can reuse it. */
function buildMcpLogicCopy(source, sourceText) {
return (
"// GENERATED — do not edit by hand. Copy of a web module for the MCP server.\n" +
`// Source: src/Web/autopilot-monitor-web/${source}\n` +
"// Regenerate: npm run generate:mcp-logic in src/Web/autopilot-monitor-web.\n\n" +
sourceText.replace(/\r\n/g, "\n")
);
}
function main() {
fs.mkdirSync(MCP_GENERATED, { recursive: true });
for (const m of MODULES) {
const text = fs.readFileSync(path.join(WEB_ROOT, m.source), "utf8");
if (/^\s*import\s/m.test(text)) {
console.error(`[mcp-logic] ${m.source} imports another module; copied modules must be self-contained.`);
process.exit(1);
}
const dest = path.join(MCP_GENERATED, m.dest);
fs.writeFileSync(dest, buildMcpLogicCopy(m.source, text), "utf8");
console.log(`[mcp-logic] Wrote ${path.relative(WEB_ROOT, dest)}`);
}
}
if (require.main === module) {
main();
}
module.exports = { MODULES, buildMcpLogicCopy };
@@ -106,6 +106,9 @@ const MCP_VOCABULARIES = [
doc: "Telemetry stores behind the operator KQL proxy — query_backend_logs `source` (C# LogQuerySources)." },
{ section: "apiErrorCodes", constName: "API_ERROR_CODES", typeName: "ApiErrorCode",
doc: "Every machine-readable `code` an error envelope can carry (C# Constants.ApiErrorCodes + DelegationCodes + DelegatedSlots)." },
// kind "map": a section of string lists keyed by name, emitted as a record of arrays.
{ section: "analyzeRuleEvidenceKeys", constName: "ANALYZE_RULE_EVIDENCE_KEYS", kind: "map",
doc: "Evidence keys a matched analyze-rule condition writes, per condition source — what a {{signal.key}} token can resolve (C# RuleEvidenceKeys)." },
];
/**
@@ -127,11 +130,23 @@ function buildMcpVocabulariesSource(manifestJsonText) {
"// vocabulary the backend no longer has (or omits one it gained).\n",
];
for (const { section, constName, typeName, doc } of MCP_VOCABULARIES) {
for (const { section, constName, typeName, doc, kind } of MCP_VOCABULARIES) {
const value = manifest[section];
if (value === undefined) {
throw new Error(`shared-manifests.json is missing the "${section}" section (MCP vocabulary)`);
}
if (kind === "map") {
const names = Object.keys(value);
if (names.length === 0 || names.some((n) => !Array.isArray(value[n]))) {
throw new Error(`Manifest section "${section}" must be a non-empty map of string lists`);
}
parts.push("");
parts.push(jsdoc(doc, ""));
parts.push(`export const ${constName}: Readonly<Record<string, readonly string[]>> = {`);
for (const n of names) parts.push(` ${JSON.stringify(n)}: ${JSON.stringify(value[n])},`);
parts.push(`};`);
continue;
}
// Enum sections are name→ordinal maps; the MCP only ever filters by NAME.
const values = Array.isArray(value) ? value : Object.keys(value);
if (values.length === 0) {