diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetSessionSignalsFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetSessionSignalsFunctionTests.cs
new file mode 100644
index 00000000..46ab9b94
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetSessionSignalsFunctionTests.cs
@@ -0,0 +1,61 @@
+using AutopilotMonitor.Functions.Functions.Sessions;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Tests for the pure helpers on . The HTTP-trigger
+/// end needs a live runtime harness; this file covers the deterministic query-string
+/// normalisation that decides how many rows we load.
+///
+public class GetSessionSignalsFunctionTests
+{
+ [Fact]
+ public void ParseMaxResults_defaults_when_query_param_missing()
+ {
+ var result = GetSessionSignalsFunction.ParseMaxResults("");
+
+ Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
+ }
+
+ [Fact]
+ public void ParseMaxResults_defaults_when_query_param_is_garbage()
+ {
+ var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=banana");
+
+ Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
+ }
+
+ [Fact]
+ public void ParseMaxResults_honours_valid_value_inside_range()
+ {
+ var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=250");
+
+ Assert.Equal(250, result);
+ }
+
+ [Fact]
+ public void ParseMaxResults_caps_at_MaxResultsCap()
+ {
+ var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=999999");
+
+ Assert.Equal(GetSessionSignalsFunction.MaxResultsCap, result);
+ }
+
+ [Theory]
+ [InlineData("?maxResults=0")]
+ [InlineData("?maxResults=-5")]
+ public void ParseMaxResults_floors_at_one_for_non_positive_values(string query)
+ {
+ var result = GetSessionSignalsFunction.ParseMaxResults(query);
+
+ Assert.Equal(1, result);
+ }
+
+ [Fact]
+ public void ParseMaxResults_handles_null_query_string()
+ {
+ var result = GetSessionSignalsFunction.ParseMaxResults(null!);
+
+ Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs
index 8f9a10a6..3a180240 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs
@@ -114,4 +114,61 @@ public class TableSignalRepositoryMappingTests
Assert.Equal(string.Empty, entity.GetString("PayloadJson"));
}
+
+ // ============================================================ Reverse mapping (read path)
+
+ [Fact]
+ public void FromEntity_round_trips_a_small_payload()
+ {
+ var original = new SignalRecord
+ {
+ TenantId = TenantId,
+ SessionId = SessionId,
+ SessionSignalOrdinal = 42,
+ SessionTraceOrdinal = 117,
+ Kind = "EspExiting",
+ KindSchemaVersion = 2,
+ OccurredAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc),
+ SourceOrigin = "EspAndHelloTrackerAdapter",
+ PayloadJson = "{\"kind\":\"EspExiting\",\"evidence\":{\"kind\":\"Raw\"}}",
+ };
+
+ var entity = TableSignalRepository.ToEntity(original);
+ var restored = TableSignalRepository.FromEntity(entity);
+
+ Assert.Equal(original.TenantId, restored.TenantId);
+ Assert.Equal(original.SessionId, restored.SessionId);
+ Assert.Equal(original.SessionSignalOrdinal, restored.SessionSignalOrdinal);
+ Assert.Equal(original.SessionTraceOrdinal, restored.SessionTraceOrdinal);
+ Assert.Equal(original.Kind, restored.Kind);
+ Assert.Equal(original.KindSchemaVersion, restored.KindSchemaVersion);
+ Assert.Equal(original.OccurredAtUtc, restored.OccurredAtUtc.ToUniversalTime());
+ Assert.Equal(original.SourceOrigin, restored.SourceOrigin);
+ Assert.Equal(original.PayloadJson, restored.PayloadJson);
+ }
+
+ [Fact]
+ public void FromEntity_reassembles_chunked_PayloadJson()
+ {
+ // Same-sized chunking behaviour we wrote — verifies the read path undoes it.
+ var oversized = new string('x', 75_000);
+ var original = new SignalRecord
+ {
+ TenantId = TenantId,
+ SessionId = SessionId,
+ SessionSignalOrdinal = 1,
+ SessionTraceOrdinal = 1,
+ Kind = "EspPhaseChanged",
+ KindSchemaVersion = 1,
+ OccurredAtUtc = DateTime.UtcNow,
+ SourceOrigin = "t",
+ PayloadJson = oversized,
+ };
+
+ var entity = TableSignalRepository.ToEntity(original);
+ var restored = TableSignalRepository.FromEntity(entity);
+
+ Assert.Equal(75_000, restored.PayloadJson.Length);
+ Assert.Equal(oversized, restored.PayloadJson);
+ }
}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs
index 80675f3b..298d4e7f 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -72,6 +73,54 @@ 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.Signals);
+ 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));
+ }
+
+ // RowKey is D19(SessionSignalOrdinal) — Azure Tables returns PK-scoped rows in RowKey
+ // lex order which matches numeric ordinal order (that's the whole point of the padding).
+ // Explicit sort is redundant but cheap; keeps the invariant under a test.
+ results.Sort((a, b) => a.SessionSignalOrdinal.CompareTo(b.SessionSignalOrdinal));
+ return results;
+ }
+
+ ///
+ /// Projects an Azure back into a ,
+ /// reassembling chunked PayloadJson if present. Internal for mapping tests.
+ ///
+ internal static SignalRecord FromEntity(TableEntity entity)
+ {
+ return new SignalRecord
+ {
+ TenantId = entity.GetString("TenantId") ?? string.Empty,
+ SessionId = entity.GetString("SessionId") ?? string.Empty,
+ SessionSignalOrdinal = entity.GetInt64("SessionSignalOrdinal") ?? 0,
+ SessionTraceOrdinal = entity.GetInt64("SessionTraceOrdinal") ?? 0,
+ Kind = entity.GetString("Kind") ?? string.Empty,
+ KindSchemaVersion = entity.GetInt32("KindSchemaVersion") ?? 0,
+ OccurredAtUtc = entity.GetDateTime("OccurredAtUtc") ?? default,
+ SourceOrigin = entity.GetString("SourceOrigin") ?? string.Empty,
+ PayloadJson = TableStorageChunking.ReassembleProperty(entity, "PayloadJson") ?? string.Empty,
+ };
+ }
+
///
/// Projects a onto its Azure shape.
/// Keys: PK = {TenantId}_{SessionId}, RK = {SessionSignalOrdinal:D19}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs
new file mode 100644
index 00000000..68b45d88
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs
@@ -0,0 +1,107 @@
+using System.Net;
+using AutopilotMonitor.Functions.Helpers;
+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}/signals — returns the SignalLog for a V2 session
+ /// (Plan §M5 read endpoint, consumed by the Inspector's SignalStream panel and the MCP
+ /// get_decision_signals tool).
+ ///
+ /// Tenant scoping is resolved upstream by PolicyEnforcementMiddleware via
+ /// ; Global-Admin fallback mirrors the other
+ /// session-detail read endpoints (resolve actual tenant via SessionsIndex when the
+ /// effective tenant has no data).
+ ///
+ ///
+ public class GetSessionSignalsFunction
+ {
+ /// Hard upper bound on page size — protects against unbounded result sets.
+ internal const int MaxResultsCap = 5000;
+ /// Default result set size when the caller doesn't specify ?maxResults=.
+ internal const int DefaultMaxResults = 1000;
+
+ private readonly ILogger _logger;
+ private readonly ISignalRepository _signalRepo;
+ private readonly ISessionRepository _sessionRepo;
+
+ public GetSessionSignalsFunction(
+ ILogger logger,
+ ISignalRepository signalRepo,
+ ISessionRepository sessionRepo)
+ {
+ _logger = logger;
+ _signalRepo = signalRepo;
+ _sessionRepo = sessionRepo;
+ }
+
+ [Function("GetSessionSignals")]
+ public async Task Run(
+ [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/signals")] 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 maxResults = ParseMaxResults(req.Url.Query);
+ var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]";
+ _logger.LogInformation("{Prefix} GetSessionSignals: maxResults={Max}", sessionPrefix, maxResults);
+
+ try
+ {
+ var requestCtx = req.GetRequestContext();
+
+ var signals = await _signalRepo.QueryBySessionAsync(
+ requestCtx.TargetTenantId, sessionId, maxResults);
+
+ // Global Admin cross-tenant fallback — mirror GetSessionEventsFunction pattern.
+ if (signals.Count == 0 && requestCtx.IsGlobalAdmin)
+ {
+ var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
+ if (resolvedTenantId != null &&
+ !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
+ {
+ signals = await _signalRepo.QueryBySessionAsync(resolvedTenantId, sessionId, maxResults);
+ }
+ }
+
+ return await req.OkAsync(new
+ {
+ success = true,
+ sessionId,
+ count = signals.Count,
+ truncated = signals.Count >= maxResults,
+ signals,
+ });
+ }
+ catch (Exception ex)
+ {
+ return await req.InternalServerErrorAsync(_logger, ex, $"Get signals for session '{sessionId}'");
+ }
+ }
+
+ ///
+ /// Reads ?maxResults= from the query string, clamping to [1, ].
+ /// Returns when the parameter is missing or malformed.
+ /// Takes the raw query string so the helper is unit-testable without mocking
+ /// HttpRequestData (abstract, painful to fake).
+ ///
+ internal static int ParseMaxResults(string queryString)
+ {
+ var query = System.Web.HttpUtility.ParseQueryString(queryString ?? string.Empty);
+ var raw = query["maxResults"];
+ if (string.IsNullOrEmpty(raw) || !int.TryParse(raw, out var parsed)) return DefaultMaxResults;
+ if (parsed < 1) return 1;
+ if (parsed > MaxResultsCap) return MaxResultsCap;
+ return parsed;
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
index 3e8e8fd4..a426b06e 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
@@ -139,6 +139,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "sessions", EndpointPolicy.MemberRead),
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}/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/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs
index 038b195d..86107880 100644
--- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs
+++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs
@@ -21,5 +21,13 @@ namespace AutopilotMonitor.Shared.DataAccess
/// Upserts a batch of signal records. Returns the number of rows committed.
///
Task StoreBatchAsync(IReadOnlyList records, CancellationToken cancellationToken = default);
+
+ ///
+ /// Reads up to signal records for a single session,
+ /// ordered by ascending. Used by the
+ /// Inspector read endpoint (GET /api/sessions/{id}/signals, Plan §M5).
+ ///
+ Task> QueryBySessionAsync(
+ string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default);
}
}