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; }
+ }
+}