diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs
new file mode 100644
index 00000000..c95bc367
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs
@@ -0,0 +1,127 @@
+using AutopilotMonitor.Functions.DataAccess.TableStorage;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Pure mapping tests for . Sibling of
+/// — same rationale.
+///
+public class TableDecisionTransitionRepositoryMappingTests
+{
+ private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
+
+ [Fact]
+ public void BuildPartitionKey_is_tenant_underscore_session()
+ {
+ var pk = TableDecisionTransitionRepository.BuildPartitionKey(TenantId, SessionId);
+ Assert.Equal($"{TenantId}_{SessionId}", pk);
+ }
+
+ [Fact]
+ public void BuildRowKey_pads_stepIndex_to_10_digits()
+ {
+ Assert.Equal("0000000000", TableDecisionTransitionRepository.BuildRowKey(0));
+ Assert.Equal("0000000042", TableDecisionTransitionRepository.BuildRowKey(42));
+ Assert.Equal("2147483647", TableDecisionTransitionRepository.BuildRowKey(int.MaxValue));
+ }
+
+ [Fact]
+ public void ToEntity_projects_all_typed_columns()
+ {
+ var record = 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(record);
+
+ Assert.Equal($"{TenantId}_{SessionId}", entity.PartitionKey);
+ Assert.Equal("0000000007", entity.RowKey);
+ Assert.Equal(TenantId, entity.GetString("TenantId"));
+ Assert.Equal(SessionId, entity.GetString("SessionId"));
+ Assert.Equal(7, entity.GetInt32("StepIndex"));
+ Assert.Equal(117L, entity.GetInt64("SessionTraceOrdinal"));
+ Assert.Equal(42L, entity.GetInt64("SignalOrdinalRef"));
+ Assert.Equal(record.OccurredAtUtc, entity.GetDateTime("OccurredAtUtc"));
+ Assert.Equal("EspExiting", entity.GetString("Trigger"));
+ Assert.Equal("EspInProgress", entity.GetString("FromStage"));
+ Assert.Equal("WhiteGloveSealed", entity.GetString("ToStage"));
+ Assert.Equal(true, entity.GetBoolean("Taken"));
+ Assert.Null(entity.GetString("DeadEndReason"));
+ Assert.Equal("1.0.0", entity.GetString("ReducerVersion"));
+ Assert.Equal(true, entity.GetBoolean("IsTerminal"));
+ Assert.Equal("whiteglove-sealing", entity.GetString("ClassifierVerdictId"));
+ Assert.Equal("Strong", entity.GetString("ClassifierHypothesisLevel"));
+ Assert.Equal("{\"foo\":\"bar\"}", entity.GetString("PayloadJson"));
+ }
+
+ [Fact]
+ public void ToEntity_preserves_dead_end_reason_for_blocked_transitions()
+ {
+ var record = new DecisionTransitionRecord
+ {
+ TenantId = TenantId,
+ SessionId = SessionId,
+ StepIndex = 3,
+ SessionTraceOrdinal = 5,
+ SignalOrdinalRef = 3,
+ OccurredAtUtc = DateTime.UtcNow,
+ Trigger = "EspExiting",
+ FromStage = "EspInProgress",
+ ToStage = "EspInProgress", // no transition — dead end
+ Taken = false,
+ DeadEndReason = "hybrid_reboot_gate_blocking",
+ ReducerVersion = "1.0.0",
+ IsTerminal = false,
+ PayloadJson = "{}",
+ };
+
+ var entity = TableDecisionTransitionRepository.ToEntity(record);
+
+ Assert.Equal(false, entity.GetBoolean("Taken"));
+ Assert.Equal("hybrid_reboot_gate_blocking", entity.GetString("DeadEndReason"));
+ }
+
+ [Fact]
+ public void ToEntity_chunks_oversized_PayloadJson()
+ {
+ var oversized = new string('x', 75_000);
+ var record = 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(record);
+
+ Assert.Equal("3", entity.GetString("PayloadJson_ChunkCount"));
+ Assert.False(entity.ContainsKey("PayloadJson"));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs
new file mode 100644
index 00000000..8f9a10a6
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs
@@ -0,0 +1,117 @@
+using AutopilotMonitor.Functions.DataAccess.TableStorage;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Pure mapping tests for . The transaction pipeline itself
+/// needs a live Azurite harness (out of scope for unit tests); the deterministic bit — how the
+/// record projects onto the Azure Table entity — is covered here.
+///
+public class TableSignalRepositoryMappingTests
+{
+ private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
+
+ [Fact]
+ public void BuildPartitionKey_is_tenant_underscore_session()
+ {
+ var pk = TableSignalRepository.BuildPartitionKey(TenantId, SessionId);
+ Assert.Equal($"{TenantId}_{SessionId}", pk);
+ }
+
+ [Fact]
+ public void BuildRowKey_pads_ordinal_to_19_digits_for_lex_ordering()
+ {
+ Assert.Equal("0000000000000000000", TableSignalRepository.BuildRowKey(0));
+ Assert.Equal("0000000000000000042", TableSignalRepository.BuildRowKey(42));
+ Assert.Equal("9223372036854775807", TableSignalRepository.BuildRowKey(long.MaxValue));
+ }
+
+ [Fact]
+ public void BuildRowKey_orders_lexicographically_matching_numeric_order()
+ {
+ var a = TableSignalRepository.BuildRowKey(9);
+ var b = TableSignalRepository.BuildRowKey(10);
+ // 19-digit padding means string comparison matches numeric order (the property of the key).
+ Assert.True(string.CompareOrdinal(a, b) < 0);
+ }
+
+ [Fact]
+ public void ToEntity_projects_all_typed_columns()
+ {
+ var record = new SignalRecord
+ {
+ TenantId = TenantId,
+ SessionId = SessionId,
+ SessionSignalOrdinal = 17,
+ SessionTraceOrdinal = 117,
+ Kind = "EspPhaseChanged",
+ KindSchemaVersion = 2,
+ OccurredAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc),
+ SourceOrigin = "EspAndHelloTrackerAdapter",
+ PayloadJson = "{\"foo\":\"bar\"}",
+ };
+
+ var entity = TableSignalRepository.ToEntity(record);
+
+ Assert.Equal($"{TenantId}_{SessionId}", entity.PartitionKey);
+ Assert.Equal("0000000000000000017", entity.RowKey);
+ Assert.Equal(TenantId, entity.GetString("TenantId"));
+ Assert.Equal(SessionId, entity.GetString("SessionId"));
+ Assert.Equal(17L, entity.GetInt64("SessionSignalOrdinal"));
+ Assert.Equal(117L, entity.GetInt64("SessionTraceOrdinal"));
+ Assert.Equal("EspPhaseChanged", entity.GetString("Kind"));
+ Assert.Equal(2, entity.GetInt32("KindSchemaVersion"));
+ Assert.Equal(record.OccurredAtUtc, entity.GetDateTime("OccurredAtUtc"));
+ Assert.Equal("EspAndHelloTrackerAdapter", entity.GetString("SourceOrigin"));
+ Assert.Equal("{\"foo\":\"bar\"}", entity.GetString("PayloadJson"));
+ }
+
+ [Fact]
+ public void ToEntity_chunks_oversized_PayloadJson_to_survive_the_32K_char_per_property_limit()
+ {
+ var oversized = new string('x', 75_000);
+ var record = new SignalRecord
+ {
+ TenantId = TenantId,
+ SessionId = SessionId,
+ SessionSignalOrdinal = 1,
+ SessionTraceOrdinal = 1,
+ Kind = "EspPhaseChanged",
+ KindSchemaVersion = 1,
+ OccurredAtUtc = DateTime.UtcNow,
+ SourceOrigin = "test",
+ PayloadJson = oversized,
+ };
+
+ var entity = TableSignalRepository.ToEntity(record);
+
+ Assert.Equal("3", entity.GetString("PayloadJson_ChunkCount"));
+ Assert.Equal(30_000, entity.GetString("PayloadJson_0").Length);
+ Assert.Equal(30_000, entity.GetString("PayloadJson_1").Length);
+ Assert.Equal(15_000, entity.GetString("PayloadJson_2").Length);
+ Assert.False(entity.ContainsKey("PayloadJson"));
+ }
+
+ [Fact]
+ public void ToEntity_tolerates_null_PayloadJson_without_throwing()
+ {
+ var record = new SignalRecord
+ {
+ TenantId = TenantId,
+ SessionId = SessionId,
+ SessionSignalOrdinal = 1,
+ SessionTraceOrdinal = 1,
+ Kind = "EspPhaseChanged",
+ KindSchemaVersion = 1,
+ OccurredAtUtc = DateTime.UtcNow,
+ SourceOrigin = "test",
+ PayloadJson = null!,
+ };
+
+ var entity = TableSignalRepository.ToEntity(record);
+
+ Assert.Equal(string.Empty, entity.GetString("PayloadJson"));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs
index 14749faa..8143e456 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs
@@ -36,6 +36,10 @@ namespace AutopilotMonitor.Functions.DataAccess
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+
+ // V2 Decision Engine repositories (Plan §M5).
+ services.AddSingleton();
+ services.AddSingleton();
return services;
}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs
new file mode 100644
index 00000000..db228806
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs
@@ -0,0 +1,120 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.Data.Tables;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.DataAccess.TableStorage
+{
+ ///
+ /// Azure Tables implementation of . Upserts to
+ /// the DecisionTransitions table via entity-group-transactions (chunked to the 100-op
+ /// limit).
+ ///
+ public sealed class TableDecisionTransitionRepository : IDecisionTransitionRepository
+ {
+ /// Azure Table Storage limit per entity-group-transaction.
+ internal const int TransactionChunkSize = 100;
+
+ private readonly TableStorageService _storage;
+ private readonly ILogger _logger;
+
+ public TableDecisionTransitionRepository(
+ TableStorageService storage,
+ ILogger logger)
+ {
+ _storage = storage;
+ _logger = logger;
+ }
+
+ public async Task StoreBatchAsync(
+ IReadOnlyList records,
+ CancellationToken cancellationToken = default)
+ {
+ if (records == null || records.Count == 0) return 0;
+
+ foreach (var r in records)
+ {
+ SecurityValidator.EnsureValidGuid(r.TenantId, "TenantId");
+ SecurityValidator.EnsureValidGuid(r.SessionId, "SessionId");
+ }
+
+ var table = _storage.GetTableClient(Constants.TableNames.DecisionTransitions);
+ var committed = 0;
+
+ foreach (var group in records.GroupBy(r => (r.TenantId, r.SessionId)))
+ {
+ var ordered = group.OrderBy(r => r.StepIndex).ToList();
+
+ for (var offset = 0; offset < ordered.Count; offset += TransactionChunkSize)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var chunk = ordered.Skip(offset).Take(TransactionChunkSize).ToList();
+ var actions = chunk
+ .Select(r => new TableTransactionAction(
+ TableTransactionActionType.UpsertReplace,
+ ToEntity(r)))
+ .ToList();
+
+ await table.SubmitTransactionAsync(actions, cancellationToken).ConfigureAwait(false);
+ committed += chunk.Count;
+ }
+
+ _logger.LogDebug(
+ "DecisionTransitions: committed {Count} rows for {Tenant}_{Session}",
+ ordered.Count, group.Key.TenantId, group.Key.SessionId);
+ }
+
+ return committed;
+ }
+
+ ///
+ /// Projects a onto its Azure
+ /// shape. Keys: PK = {TenantId}_{SessionId}, RK = {StepIndex:D10}.
+ ///
+ internal static TableEntity ToEntity(DecisionTransitionRecord r)
+ {
+ var pk = BuildPartitionKey(r.TenantId, r.SessionId);
+ var rk = BuildRowKey(r.StepIndex);
+
+ var entity = new TableEntity(pk, rk)
+ {
+ ["TenantId"] = r.TenantId,
+ ["SessionId"] = r.SessionId,
+ ["StepIndex"] = r.StepIndex,
+ ["SessionTraceOrdinal"] = r.SessionTraceOrdinal,
+ ["SignalOrdinalRef"] = r.SignalOrdinalRef,
+ ["OccurredAtUtc"] = r.OccurredAtUtc,
+ ["Trigger"] = r.Trigger ?? string.Empty,
+ ["FromStage"] = r.FromStage ?? string.Empty,
+ ["ToStage"] = r.ToStage ?? string.Empty,
+ ["Taken"] = r.Taken,
+ ["DeadEndReason"] = r.DeadEndReason,
+ ["ReducerVersion"] = r.ReducerVersion ?? string.Empty,
+ ["IsTerminal"] = r.IsTerminal,
+ ["ClassifierVerdictId"] = r.ClassifierVerdictId,
+ ["ClassifierHypothesisLevel"] = r.ClassifierHypothesisLevel,
+ };
+
+ foreach (var kv in TableStorageChunking.ChunkProperty("PayloadJson", r.PayloadJson ?? string.Empty))
+ {
+ entity[kv.Key] = kv.Value;
+ }
+
+ return entity;
+ }
+
+ internal static string BuildPartitionKey(string tenantId, string sessionId)
+ => $"{tenantId}_{sessionId}";
+
+ internal static string BuildRowKey(int stepIndex)
+ => stepIndex.ToString("D10");
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs
new file mode 100644
index 00000000..80675f3b
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs
@@ -0,0 +1,112 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.Data.Tables;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.DataAccess.TableStorage
+{
+ ///
+ /// Azure Tables implementation of . Upserts to the
+ /// Signals table via entity-group-transactions (chunked to the 100-op limit).
+ ///
+ public sealed class TableSignalRepository : ISignalRepository
+ {
+ /// Azure Table Storage limit per entity-group-transaction.
+ internal const int TransactionChunkSize = 100;
+
+ private readonly TableStorageService _storage;
+ private readonly ILogger _logger;
+
+ public TableSignalRepository(TableStorageService storage, ILogger logger)
+ {
+ _storage = storage;
+ _logger = logger;
+ }
+
+ public async Task StoreBatchAsync(
+ IReadOnlyList records,
+ CancellationToken cancellationToken = default)
+ {
+ if (records == null || records.Count == 0) return 0;
+
+ foreach (var r in records)
+ {
+ SecurityValidator.EnsureValidGuid(r.TenantId, "TenantId");
+ SecurityValidator.EnsureValidGuid(r.SessionId, "SessionId");
+ }
+
+ var table = _storage.GetTableClient(Constants.TableNames.Signals);
+ var committed = 0;
+
+ foreach (var group in records.GroupBy(r => (r.TenantId, r.SessionId)))
+ {
+ var ordered = group.OrderBy(r => r.SessionSignalOrdinal).ToList();
+
+ for (var offset = 0; offset < ordered.Count; offset += TransactionChunkSize)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var chunk = ordered.Skip(offset).Take(TransactionChunkSize).ToList();
+ var actions = chunk
+ .Select(r => new TableTransactionAction(
+ TableTransactionActionType.UpsertReplace,
+ ToEntity(r)))
+ .ToList();
+
+ await table.SubmitTransactionAsync(actions, cancellationToken).ConfigureAwait(false);
+ committed += chunk.Count;
+ }
+
+ _logger.LogDebug(
+ "Signals: committed {Count} rows for {Tenant}_{Session}",
+ ordered.Count, group.Key.TenantId, group.Key.SessionId);
+ }
+
+ return committed;
+ }
+
+ ///
+ /// Projects a onto its Azure shape.
+ /// Keys: PK = {TenantId}_{SessionId}, RK = {SessionSignalOrdinal:D19}
+ /// (19 digits covers long.MaxValue for lexicographic ordering).
+ ///
+ internal static TableEntity ToEntity(SignalRecord r)
+ {
+ var pk = BuildPartitionKey(r.TenantId, r.SessionId);
+ var rk = BuildRowKey(r.SessionSignalOrdinal);
+
+ var entity = new TableEntity(pk, rk)
+ {
+ ["TenantId"] = r.TenantId,
+ ["SessionId"] = r.SessionId,
+ ["SessionSignalOrdinal"] = r.SessionSignalOrdinal,
+ ["SessionTraceOrdinal"] = r.SessionTraceOrdinal,
+ ["Kind"] = r.Kind ?? string.Empty,
+ ["KindSchemaVersion"] = r.KindSchemaVersion,
+ ["OccurredAtUtc"] = r.OccurredAtUtc,
+ ["SourceOrigin"] = r.SourceOrigin ?? string.Empty,
+ };
+
+ // Guard the 32 K-char per-property limit — DecisionSignal Evidence+Payload can cross it.
+ foreach (var kv in TableStorageChunking.ChunkProperty("PayloadJson", r.PayloadJson ?? string.Empty))
+ {
+ entity[kv.Key] = kv.Value;
+ }
+
+ return entity;
+ }
+
+ internal static string BuildPartitionKey(string tenantId, string sessionId)
+ => $"{tenantId}_{sessionId}";
+
+ internal static string BuildRowKey(long sessionSignalOrdinal)
+ => sessionSignalOrdinal.ToString("D19");
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index a84970cc..a53539e7 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -343,6 +343,12 @@ namespace AutopilotMonitor.Shared
// Lightweight index of sessions that have events (for orphan detection)
public const string EventSessionIndex = "EventSessionIndex";
+ // V2 Decision Engine primary tables (Plan §M5).
+ // SignalLog (input-truth) and Journal (decision-truth) projected to the backend for
+ // the Inspector + Reducer-Verifier. Both partitioned by {TenantId}_{SessionId}.
+ public const string Signals = "Signals";
+ public const string DecisionTransitions = "DecisionTransitions";
+
///
/// Returns all table names for initialization
///
@@ -384,7 +390,9 @@ namespace AutopilotMonitor.Shared
OpsEvents,
ImeVersionHistory,
RuleStats,
- EventSessionIndex
+ EventSessionIndex,
+ Signals,
+ DecisionTransitions
};
}
}
diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs
new file mode 100644
index 00000000..378011c9
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs
@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Shared.DataAccess
+{
+ ///
+ /// Repository for the DecisionTransitions primary table (Plan §M5). Writes projected
+ /// s derived from agent Journal entries.
+ ///
+ /// Writes are idempotent via (PartitionKey, RowKey): a retried batch replays without
+ /// duplicating rows. Records within a single batch must share the same (TenantId, SessionId).
+ ///
+ ///
+ public interface IDecisionTransitionRepository
+ {
+ ///
+ /// Upserts a batch of transition records. Returns the number of rows committed.
+ ///
+ Task StoreBatchAsync(IReadOnlyList records, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs
new file mode 100644
index 00000000..038b195d
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Shared.DataAccess
+{
+ ///
+ /// Repository for the Signals primary table (Plan §M5). Writes projected
+ /// s derived from agent-emitted DecisionSignals.
+ ///
+ /// Writes are idempotent via (PartitionKey, RowKey): a retried batch replays
+ /// without duplicating rows. Records within a single batch must share the same
+ /// (TenantId, SessionId) — the repository groups/chunks per the Azure Tables 100-op
+ /// transaction limit.
+ ///
+ ///
+ public interface ISignalRepository
+ {
+ ///
+ /// Upserts a batch of signal records. Returns the number of rows committed.
+ ///
+ Task StoreBatchAsync(IReadOnlyList records, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/DecisionTransitionRecord.cs b/src/Shared/AutopilotMonitor.Shared/Models/DecisionTransitionRecord.cs
new file mode 100644
index 00000000..c3518482
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.Shared/Models/DecisionTransitionRecord.cs
@@ -0,0 +1,59 @@
+using System;
+
+namespace AutopilotMonitor.Shared.Models
+{
+ ///
+ /// Backend storage record for a single DecisionTransition journal step (Plan §M5).
+ /// Flat shape projected from the agent's DecisionTransition — see
+ /// for the overall projection pattern.
+ ///
+ /// PK = {TenantId}_{SessionId}, RK = {StepIndex:D10}.
+ ///
+ ///
+ /// The index-discriminator columns (, ,
+ /// ) are projected eagerly so index-table fan-out
+ /// (SessionsByTerminal / ClassifierVerdictsByIdLevel / …) can happen without re-parsing
+ /// . The primary write stores them inline; the queue-driven
+ /// index writer reads them straight off the primary row.
+ ///
+ ///
+ public sealed class DecisionTransitionRecord
+ {
+ public string TenantId { get; set; } = string.Empty;
+ public string SessionId { get; set; } = string.Empty;
+
+ /// Monotonic per Journal. Drives table RowKey ordering.
+ public int StepIndex { get; set; }
+
+ /// Session-wide monotonic across Event + Signal + Transition.
+ public long SessionTraceOrdinal { get; set; }
+
+ /// Plan §2.2 cross-reference — which signal caused this transition.
+ public long SignalOrdinalRef { get; set; }
+
+ public DateTime OccurredAtUtc { get; set; }
+
+ public string Trigger { get; set; } = string.Empty;
+
+ /// SessionStage enum name.
+ public string FromStage { get; set; } = string.Empty;
+ public string ToStage { get; set; } = string.Empty;
+
+ /// false = dead-end (blocked path, non-null).
+ public bool Taken { get; set; }
+
+ public string? DeadEndReason { get; set; }
+
+ public string ReducerVersion { get; set; } = string.Empty;
+
+ /// Projected index discriminator — true when is a terminal stage.
+ public bool IsTerminal { get; set; }
+
+ /// Projected index discriminator — non-null when this step embeds a classifier verdict.
+ public string? ClassifierVerdictId { get; set; }
+ public string? ClassifierHypothesisLevel { get; set; }
+
+ /// Agent-serialized DecisionTransition JSON (Guards + EmittedEventSequences + ClassifierVerdict included).
+ public string PayloadJson { get; set; } = string.Empty;
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SignalRecord.cs b/src/Shared/AutopilotMonitor.Shared/Models/SignalRecord.cs
new file mode 100644
index 00000000..28185153
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.Shared/Models/SignalRecord.cs
@@ -0,0 +1,43 @@
+using System;
+
+namespace AutopilotMonitor.Shared.Models
+{
+ ///
+ /// Backend storage record for a single DecisionSignal (Plan §M5). Flat shape projected
+ /// from the agent's DecisionSignal so the Backend doesn't need to reference DecisionCore
+ /// just to persist and serve it back out.
+ ///
+ /// Keys (authoritative, supplied by the agent for idempotent upsert via
+ /// (PartitionKey, RowKey)):
+ /// PK = {TenantId}_{SessionId}, RK = {SessionSignalOrdinal:D19}.
+ ///
+ ///
+ /// Fidelity: carries the complete agent-serialized
+ /// DecisionSignal (including Evidence + Payload dictionary). Typed columns exist only
+ /// for query/projection; replay is driven off the JSON blob.
+ ///
+ ///
+ public sealed class SignalRecord
+ {
+ public string TenantId { get; set; } = string.Empty;
+ public string SessionId { get; set; } = string.Empty;
+
+ /// Monotonic per SignalLog. Drives table RowKey ordering.
+ public long SessionSignalOrdinal { get; set; }
+
+ /// Session-wide monotonic across Event + Signal + Transition. Inspector correlation only.
+ public long SessionTraceOrdinal { get; set; }
+
+ /// DecisionSignalKind enum name — stored as string for forward-compat.
+ public string Kind { get; set; } = string.Empty;
+
+ public int KindSchemaVersion { get; set; }
+
+ public DateTime OccurredAtUtc { get; set; }
+
+ public string SourceOrigin { get; set; } = string.Empty;
+
+ /// Agent-serialized DecisionSignal JSON (Evidence + Payload included).
+ public string PayloadJson { get; set; } = string.Empty;
+ }
+}