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