diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DecisionGraphBuilderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DecisionGraphBuilderTests.cs new file mode 100644 index 00000000..7611e771 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DecisionGraphBuilderTests.cs @@ -0,0 +1,180 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for — pure projection, zero IO. Covers node +/// de-duplication, terminal classification, dead-end preservation, visit-count semantics, and +/// empty / out-of-order inputs. +/// +public class DecisionGraphBuilderTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static DecisionTransitionRecord Transition( + int step, string from, string to, bool taken = true, string? deadEnd = null, string trigger = "t", + string reducer = "1.0.0") + => new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = step, + FromStage = from, + ToStage = to, + Taken = taken, + DeadEndReason = deadEnd, + Trigger = trigger, + ReducerVersion = reducer, + IsTerminal = DecisionGraphBuilder.IsTerminalStage(to), + OccurredAtUtc = new DateTime(2026, 4, 21, 10, step, 0, DateTimeKind.Utc), + }; + + // ============================================================ Empty input + + [Fact] + public void Build_returns_empty_projection_when_no_transitions() + { + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, Array.Empty()); + + Assert.Equal(TenantId, graph.TenantId); + Assert.Equal(SessionId, graph.SessionId); + Assert.Empty(graph.Nodes); + Assert.Empty(graph.Edges); + Assert.Equal(string.Empty, graph.ReducerVersion); + } + + // ============================================================ Node de-duplication + + [Fact] + public void Build_deduplicates_stages_into_unique_nodes() + { + // Same stage visited twice through different paths — appears once in Nodes. + var transitions = new[] + { + Transition(0, "SessionStarted", "EspInProgress"), + Transition(1, "EspInProgress", "AccountSetup"), + Transition(2, "AccountSetup", "EspInProgress"), // back-edge (hypothetical) + Transition(3, "EspInProgress", "Completed"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + // Unique stages: SessionStarted, EspInProgress, AccountSetup, Completed = 4 nodes. + Assert.Equal(4, graph.Nodes.Count); + Assert.Contains(graph.Nodes, n => n.Id == "SessionStarted"); + Assert.Contains(graph.Nodes, n => n.Id == "EspInProgress"); + Assert.Contains(graph.Nodes, n => n.Id == "AccountSetup"); + Assert.Contains(graph.Nodes, n => n.Id == "Completed"); + } + + // ============================================================ Terminal classification + + [Theory] + [InlineData("Completed", "Succeeded")] + [InlineData("Failed", "Failed")] + [InlineData("WhiteGloveSealed", "PausedForPart2")] + [InlineData("WhiteGloveCompletedPart2", "Succeeded")] + public void Build_marks_terminal_stages_and_derives_outcome(string terminalStage, string expectedOutcome) + { + var transitions = new[] + { + Transition(0, "Start", terminalStage), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + var node = graph.Nodes.Single(n => n.Id == terminalStage); + + Assert.True(node.IsTerminal); + Assert.Equal(expectedOutcome, node.TerminalOutcome); + } + + [Fact] + public void Build_leaves_non_terminal_nodes_unmarked() + { + var transitions = new[] { Transition(0, "SessionStarted", "EspInProgress") }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.All(graph.Nodes, n => + { + Assert.False(n.IsTerminal); + Assert.Null(n.TerminalOutcome); + }); + } + + // ============================================================ Edges + + [Fact] + public void Build_preserves_all_edges_including_dead_ends() + { + var transitions = new[] + { + Transition(0, "EspInProgress", "Completed", taken: true), + Transition(1, "EspInProgress", "EspInProgress", taken: false, + deadEnd: "hybrid_reboot_gate_blocking", trigger: "EspExiting"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.Equal(2, graph.Edges.Count); + Assert.Contains(graph.Edges, e => e.Taken && e.ToStage == "Completed"); + var deadEnd = graph.Edges.Single(e => !e.Taken); + Assert.Equal("hybrid_reboot_gate_blocking", deadEnd.DeadEndReason); + Assert.Equal("EspExiting", deadEnd.Trigger); + } + + [Fact] + public void Build_orders_edges_by_StepIndex_even_if_input_is_shuffled() + { + // Repository sorts, but defend anyway — the builder is used directly in tests and + // could be fed arbitrary lists. + var transitions = new[] + { + Transition(2, "AccountSetup", "Completed"), + Transition(0, "SessionStarted", "EspInProgress"), + Transition(1, "EspInProgress", "AccountSetup"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.Equal(new[] { 0, 1, 2 }, graph.Edges.Select(e => e.StepIndex)); + } + + // ============================================================ VisitCount + + [Fact] + public void Build_counts_only_taken_transitions_toward_VisitCount() + { + var transitions = new[] + { + Transition(0, "Start", "EspInProgress", taken: true), + Transition(1, "EspInProgress", "EspInProgress", taken: false, deadEnd: "blocked"), + Transition(2, "EspInProgress", "Completed", taken: true), + Transition(3, "Start", "EspInProgress", taken: true), // hypothetical retry + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + var esp = graph.Nodes.Single(n => n.Id == "EspInProgress"); + Assert.Equal(2, esp.VisitCount); // two taken → EspInProgress; the dead-end does not count + + var completed = graph.Nodes.Single(n => n.Id == "Completed"); + Assert.Equal(1, completed.VisitCount); + } + + [Fact] + public void Build_exposes_ReducerVersion_from_first_row() + { + var transitions = new[] + { + Transition(0, "Start", "EspInProgress", reducer: "1.4.2"), + Transition(1, "EspInProgress", "Completed", reducer: "1.4.2"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.Equal("1.4.2", graph.ReducerVersion); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs index c95bc367..7cb55cbf 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs @@ -124,4 +124,77 @@ public class TableDecisionTransitionRepositoryMappingTests Assert.Equal("3", entity.GetString("PayloadJson_ChunkCount")); Assert.False(entity.ContainsKey("PayloadJson")); } + + // ============================================================ Reverse mapping (read path) + + [Fact] + public void FromEntity_round_trips_all_typed_columns() + { + var original = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 7, + SessionTraceOrdinal = 117, + SignalOrdinalRef = 42, + OccurredAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc), + Trigger = "EspExiting", + FromStage = "EspInProgress", + ToStage = "WhiteGloveSealed", + Taken = true, + DeadEndReason = null, + ReducerVersion = "1.0.0", + IsTerminal = true, + ClassifierVerdictId = "whiteglove-sealing", + ClassifierHypothesisLevel = "Strong", + PayloadJson = "{\"foo\":\"bar\"}", + }; + + var entity = TableDecisionTransitionRepository.ToEntity(original); + var restored = TableDecisionTransitionRepository.FromEntity(entity); + + Assert.Equal(original.TenantId, restored.TenantId); + Assert.Equal(original.SessionId, restored.SessionId); + Assert.Equal(original.StepIndex, restored.StepIndex); + Assert.Equal(original.SessionTraceOrdinal, restored.SessionTraceOrdinal); + Assert.Equal(original.SignalOrdinalRef, restored.SignalOrdinalRef); + Assert.Equal(original.OccurredAtUtc, restored.OccurredAtUtc.ToUniversalTime()); + Assert.Equal(original.Trigger, restored.Trigger); + Assert.Equal(original.FromStage, restored.FromStage); + Assert.Equal(original.ToStage, restored.ToStage); + Assert.Equal(original.Taken, restored.Taken); + Assert.Equal(original.DeadEndReason, restored.DeadEndReason); + Assert.Equal(original.ReducerVersion, restored.ReducerVersion); + Assert.Equal(original.IsTerminal, restored.IsTerminal); + Assert.Equal(original.ClassifierVerdictId, restored.ClassifierVerdictId); + Assert.Equal(original.ClassifierHypothesisLevel, restored.ClassifierHypothesisLevel); + Assert.Equal(original.PayloadJson, restored.PayloadJson); + } + + [Fact] + public void FromEntity_reassembles_chunked_PayloadJson() + { + var oversized = new string('y', 75_000); + var original = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 1, + SessionTraceOrdinal = 1, + SignalOrdinalRef = 1, + OccurredAtUtc = DateTime.UtcNow, + Trigger = "t", + FromStage = "F", + ToStage = "T", + Taken = true, + ReducerVersion = "1.0.0", + PayloadJson = oversized, + }; + + var entity = TableDecisionTransitionRepository.ToEntity(original); + var restored = TableDecisionTransitionRepository.FromEntity(entity); + + Assert.Equal(75_000, restored.PayloadJson.Length); + Assert.Equal(oversized, restored.PayloadJson); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs index db228806..13874e60 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs @@ -75,6 +75,58 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return committed; } + public async Task> QueryBySessionAsync( + string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default) + { + SecurityValidator.EnsureValidGuid(tenantId, "TenantId"); + SecurityValidator.EnsureValidGuid(sessionId, "SessionId"); + + var table = _storage.GetTableClient(Constants.TableNames.DecisionTransitions); + var pk = BuildPartitionKey(tenantId, sessionId); + + var results = new List(capacity: Math.Min(maxResults, 128)); + var pages = table.QueryAsync( + filter: $"PartitionKey eq '{pk}'", + maxPerPage: Math.Min(maxResults, 1000), + cancellationToken: cancellationToken); + + await foreach (var entity in pages.ConfigureAwait(false)) + { + if (results.Count >= maxResults) break; + results.Add(FromEntity(entity)); + } + + results.Sort((a, b) => a.StepIndex.CompareTo(b.StepIndex)); + return results; + } + + /// + /// Projects an Azure back into a , + /// reassembling chunked PayloadJson if present. Internal for mapping tests. + /// + internal static DecisionTransitionRecord FromEntity(TableEntity entity) + { + return new DecisionTransitionRecord + { + TenantId = entity.GetString("TenantId") ?? string.Empty, + SessionId = entity.GetString("SessionId") ?? string.Empty, + StepIndex = entity.GetInt32("StepIndex") ?? 0, + SessionTraceOrdinal = entity.GetInt64("SessionTraceOrdinal") ?? 0, + SignalOrdinalRef = entity.GetInt64("SignalOrdinalRef") ?? 0, + OccurredAtUtc = entity.GetDateTime("OccurredAtUtc") ?? default, + Trigger = entity.GetString("Trigger") ?? string.Empty, + FromStage = entity.GetString("FromStage") ?? string.Empty, + ToStage = entity.GetString("ToStage") ?? string.Empty, + Taken = entity.GetBoolean("Taken") ?? false, + DeadEndReason = entity.GetString("DeadEndReason"), + ReducerVersion = entity.GetString("ReducerVersion") ?? string.Empty, + IsTerminal = entity.GetBoolean("IsTerminal") ?? false, + ClassifierVerdictId = entity.GetString("ClassifierVerdictId"), + ClassifierHypothesisLevel = entity.GetString("ClassifierHypothesisLevel"), + PayloadJson = TableStorageChunking.ReassembleProperty(entity, "PayloadJson") ?? string.Empty, + }; + } + /// /// Projects a onto its Azure /// shape. Keys: PK = {TenantId}_{SessionId}, RK = {StepIndex:D10}. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs new file mode 100644 index 00000000..723c629a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs @@ -0,0 +1,88 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Sessions +{ + /// + /// GET /api/sessions/{sessionId}/decision-graph — returns a pre-projected DAG of + /// the session's reducer steps for the Inspector (Plan §M5 / §M6). Building the graph + /// backend-side means the UI receives one structured shape instead of rebuilding it from + /// the raw journal on every page open; it also keeps the "what counts as terminal" rule + /// authoritatively next to 's copy of that list. + /// + public class GetSessionDecisionGraphFunction + { + /// Sane upper bound — a V2 session journal shouldn't produce more steps than this. + internal const int MaxTransitionsToLoad = 5000; + + private readonly ILogger _logger; + private readonly IDecisionTransitionRepository _transitionRepo; + private readonly ISessionRepository _sessionRepo; + + public GetSessionDecisionGraphFunction( + ILogger logger, + IDecisionTransitionRepository transitionRepo, + ISessionRepository sessionRepo) + { + _logger = logger; + _transitionRepo = transitionRepo; + _sessionRepo = sessionRepo; + } + + [Function("GetSessionDecisionGraph")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/decision-graph")] HttpRequestData req, + string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = "SessionId is required" }); + return badRequest; + } + + var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]"; + _logger.LogInformation("{Prefix} GetSessionDecisionGraph", sessionPrefix); + + try + { + var requestCtx = req.GetRequestContext(); + + var transitions = await _transitionRepo.QueryBySessionAsync( + requestCtx.TargetTenantId, sessionId, MaxTransitionsToLoad); + + // Global Admin cross-tenant fallback (same pattern as other session-detail reads). + var effectiveTenantId = requestCtx.TargetTenantId; + if (transitions.Count == 0 && requestCtx.IsGlobalAdmin) + { + var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId); + if (resolvedTenantId != null && + !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase)) + { + transitions = await _transitionRepo.QueryBySessionAsync( + resolvedTenantId, sessionId, MaxTransitionsToLoad); + effectiveTenantId = resolvedTenantId; + } + } + + var projection = DecisionGraphBuilder.Build(effectiveTenantId, sessionId, transitions); + + return await req.OkAsync(new + { + success = true, + truncated = transitions.Count >= MaxTransitionsToLoad, + graph = projection, + }); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, $"Get decision graph for session '{sessionId}'"); + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index a426b06e..3dc2eeeb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -140,6 +140,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "sessions/{sessionId}", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/events", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/signals", EndpointPolicy.MemberRead, TenantScoping.QueryParam), + new("GET", "sessions/{sessionId}/decision-graph", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/vulnerability-report", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "metrics/app", EndpointPolicy.MemberRead), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DecisionGraphBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/DecisionGraphBuilder.cs new file mode 100644 index 00000000..120b0c52 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/DecisionGraphBuilder.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Pure projection from the persisted journal () to + /// the renderable the Inspector consumes (Plan §M5 / + /// §M6). No IO, no service dependencies — unit-testable against canned transition lists. + /// + internal static class DecisionGraphBuilder + { + public static DecisionGraphProjection Build( + string tenantId, + string sessionId, + IReadOnlyList transitions) + { + var projection = new DecisionGraphProjection + { + TenantId = tenantId, + SessionId = sessionId, + Nodes = new List(), + Edges = new List(), + }; + + if (transitions == null || transitions.Count == 0) return projection; + + // ReducerVersion is consistent across a session (enforced by the agent-side Journal + // contract); pick the first row's value. + projection.ReducerVersion = transitions[0].ReducerVersion ?? string.Empty; + + // Build nodes by de-duplicating From+To stages. Visit count = how many edges land on + // the node (in-degree). Also track terminal flag + outcome derived from the ToStage. + var nodeMap = new Dictionary(StringComparer.Ordinal); + + DecisionGraphNode EnsureNode(string stage) + { + if (!nodeMap.TryGetValue(stage, out var node)) + { + node = new DecisionGraphNode + { + Id = stage, + IsTerminal = IsTerminalStage(stage), + TerminalOutcome = DeriveTerminalOutcome(stage), + VisitCount = 0, + }; + nodeMap[stage] = node; + } + return node; + } + + // Edges come out in StepIndex order (repository already sorts). Record them as-is — + // preserving dead-ends because the Inspector renders them in a distinct style. + foreach (var t in transitions.OrderBy(x => x.StepIndex)) + { + // Lazy-init both endpoints so isolated nodes (never-visited stages that show up + // as FromStage only) are still rendered. + var fromNode = EnsureNode(t.FromStage ?? string.Empty); + var toNode = EnsureNode(t.ToStage ?? string.Empty); + + projection.Edges.Add(new DecisionGraphEdge + { + StepIndex = t.StepIndex, + FromStage = t.FromStage ?? string.Empty, + ToStage = t.ToStage ?? string.Empty, + Trigger = t.Trigger ?? string.Empty, + Taken = t.Taken, + DeadEndReason = t.DeadEndReason, + SignalOrdinalRef = t.SignalOrdinalRef, + OccurredAtUtc = t.OccurredAtUtc, + ClassifierVerdictId = t.ClassifierVerdictId, + ClassifierHypothesisLevel = t.ClassifierHypothesisLevel, + }); + + // VisitCount counts *taken* transitions — dead-ends don't advance the visit graph. + // Matches the UI semantics: heat-map should show where execution actually went. + if (t.Taken) toNode.VisitCount++; + } + + // Emit nodes in deterministic order: by first-seen StepIndex. Graph renderers use + // insertion order as a hint when they haven't computed a layout yet. + projection.Nodes = nodeMap.Values.ToList(); + return projection; + } + + /// + /// Mirror of the agent-side SessionStageExtensions.IsTerminal() (DecisionCore) + + /// the lookup. Three sources of + /// truth stay in sync via the commit-time contract call-out (commit message + + /// feedback_table_storage_serialization). + /// + internal static bool IsTerminalStage(string stage) + { + switch (stage) + { + case "Completed": + case "Failed": + case "WhiteGloveSealed": + case "WhiteGloveCompletedPart2": + return true; + default: + return false; + } + } + + /// + /// Maps a terminal stage to a coarse outcome label for the Inspector. WhiteGloveSealed + /// is Part-1 pause (reboot expected, agent exits but session is not finished) — labelled + /// distinctly so the UI can render it with a different colour than the other terminals. + /// + internal static string? DeriveTerminalOutcome(string stage) + { + switch (stage) + { + case "Completed": + case "WhiteGloveCompletedPart2": + return "Succeeded"; + case "Failed": + return "Failed"; + case "WhiteGloveSealed": + return "PausedForPart2"; + default: + return null; + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs index 378011c9..1abdf10a 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs @@ -19,5 +19,13 @@ namespace AutopilotMonitor.Shared.DataAccess /// Upserts a batch of transition records. Returns the number of rows committed. /// Task StoreBatchAsync(IReadOnlyList records, CancellationToken cancellationToken = default); + + /// + /// Reads up to transition records for a single session, + /// ordered by ascending. Used by the + /// Inspector decision-graph endpoint (GET /api/sessions/{id}/decision-graph, Plan §M5). + /// + Task> QueryBySessionAsync( + string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default); } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/DecisionGraphProjection.cs b/src/Shared/AutopilotMonitor.Shared/Models/DecisionGraphProjection.cs new file mode 100644 index 00000000..0d088d04 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/DecisionGraphProjection.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Server-side projection of a session's s into a + /// renderable DAG for the Inspector (Plan §M5, §M6). Pre-computed on the backend so the + /// UI receives one structured shape instead of rebuilding the graph from the raw journal. + /// + public sealed class DecisionGraphProjection + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// Unique stages reached in the session (de-duplicated from Transition From/To stages). + public List Nodes { get; set; } = new List(); + + /// One entry per transition — edges preserve chronological order via . + public List Edges { get; set; } = new List(); + + /// Plan §2.10 — lets the UI flag sessions running on an older ReducerVersion than current. + public string ReducerVersion { get; set; } = string.Empty; + } + + /// + /// One stage the session visited. Identified by the enum name so + /// JSON payloads stay forward-compatible with new stages (string, not int ordinal). + /// + public sealed class DecisionGraphNode + { + /// Stage enum name (e.g. "EspInProgress"). Also used as the graph-node ID. + public string Id { get; set; } = string.Empty; + + public bool IsTerminal { get; set; } + + /// + /// Outcome label derived from the terminal — "Succeeded", + /// "Failed", "PausedForPart2", or null for non-terminal nodes. + /// The richer termination metadata (TerminationReason + TerminationOutcome + /// from the enrollment_terminated event in M4.6.β) is not inlined here — + /// a future revision can enrich terminal nodes by joining the Events table if the UI + /// needs it. Today's Inspector gets the high-level label. + /// + public string? TerminalOutcome { get; set; } + + /// Number of edges that target this node (for UI sizing / heat-map rendering). + public int VisitCount { get; set; } + } + + /// + /// One reducer step. false = dead-end edge (guard blocked the transition) + /// so the Inspector can render the blocked path in a different style. + /// + public sealed class DecisionGraphEdge + { + public int StepIndex { get; set; } + public string FromStage { get; set; } = string.Empty; + public string ToStage { get; set; } = string.Empty; + public string Trigger { get; set; } = string.Empty; + public bool Taken { get; set; } + public string? DeadEndReason { get; set; } + public long SignalOrdinalRef { get; set; } + public DateTime OccurredAtUtc { get; set; } + public string? ClassifierVerdictId { get; set; } + public string? ClassifierHypothesisLevel { get; set; } + } +}