diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index 7ed6ff82..7f8f011e 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-5
+7
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 0eb5aea3..d0378c4c 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1047
+1049
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateBuilderTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateBuilderTests.cs
new file mode 100644
index 00000000..2400d58e
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateBuilderTests.cs
@@ -0,0 +1,81 @@
+using System;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests
+{
+ public sealed class DecisionStateBuilderTests
+ {
+ [Fact]
+ public void Build_withoutChanges_returnsEquivalentState()
+ {
+ var original = DecisionState.CreateInitial("s", "t");
+ var rebuilt = original.ToBuilder().Build();
+
+ Assert.NotSame(original, rebuilt);
+ Assert.Equal(original.SessionId, rebuilt.SessionId);
+ Assert.Equal(original.Stage, rebuilt.Stage);
+ Assert.Equal(original.StepIndex, rebuilt.StepIndex);
+ Assert.Equal(original.LastAppliedSignalOrdinal, rebuilt.LastAppliedSignalOrdinal);
+ }
+
+ [Fact]
+ public void WithStage_returnsNewInstance_originalUnchanged()
+ {
+ var original = DecisionState.CreateInitial("s", "t");
+ var updated = original.ToBuilder().WithStage(SessionStage.AwaitingHello).Build();
+
+ Assert.Equal(SessionStage.SessionStarted, original.Stage);
+ Assert.Equal(SessionStage.AwaitingHello, updated.Stage);
+ }
+
+ [Fact]
+ public void AddDeadline_sameName_replacesExisting()
+ {
+ var original = DecisionState.CreateInitial("s", "t");
+ var dl1 = new ActiveDeadline(
+ name: "hello_safety",
+ dueAtUtc: new DateTime(2026, 4, 20, 10, 5, 0, DateTimeKind.Utc),
+ firesSignalKind: DecisionSignalKind.DeadlineFired);
+ var dl2 = new ActiveDeadline(
+ name: "hello_safety",
+ dueAtUtc: new DateTime(2026, 4, 20, 10, 10, 0, DateTimeKind.Utc),
+ firesSignalKind: DecisionSignalKind.DeadlineFired);
+
+ var result = original.ToBuilder().AddDeadline(dl1).AddDeadline(dl2).Build();
+
+ Assert.Single(result.Deadlines);
+ Assert.Equal(dl2.DueAtUtc, result.Deadlines[0].DueAtUtc);
+ }
+
+ [Fact]
+ public void CancelDeadline_removesByName()
+ {
+ var original = DecisionState.CreateInitial("s", "t");
+ var dl = new ActiveDeadline(
+ name: "hello_safety",
+ dueAtUtc: DateTime.UtcNow.AddMinutes(5),
+ firesSignalKind: DecisionSignalKind.DeadlineFired);
+
+ var withDeadline = original.ToBuilder().AddDeadline(dl).Build();
+ var cancelled = withDeadline.ToBuilder().CancelDeadline("hello_safety").Build();
+
+ Assert.Single(withDeadline.Deadlines);
+ Assert.Empty(cancelled.Deadlines);
+ }
+
+ [Fact]
+ public void WithCurrentEnrollmentPhase_stampsSourceOrdinal()
+ {
+ var original = DecisionState.CreateInitial("s", "t");
+ var updated = original.ToBuilder()
+ .WithCurrentEnrollmentPhase(EnrollmentPhase.AccountSetup, sourceSignalOrdinal: 7)
+ .Build();
+
+ Assert.Equal(EnrollmentPhase.AccountSetup, updated.CurrentEnrollmentPhase!.Value);
+ Assert.Equal(7, updated.CurrentEnrollmentPhase!.SourceSignalOrdinal);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/FixtureLoader.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/FixtureLoader.cs
new file mode 100644
index 00000000..fc8dfbe1
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/FixtureLoader.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using System.IO;
+using AutopilotMonitor.DecisionCore.Serialization;
+using AutopilotMonitor.DecisionCore.Signals;
+
+namespace AutopilotMonitor.DecisionCore.Tests.Harness
+{
+ ///
+ /// Load a DecisionSignal JSONL fixture from disk. Plan §4 M2 harness integration.
+ /// Comment lines starting with '#' and blank lines are ignored.
+ ///
+ public static class FixtureLoader
+ {
+ public static IReadOnlyList LoadJsonl(string path)
+ {
+ var signals = new List();
+ foreach (var rawLine in File.ReadAllLines(path))
+ {
+ var line = rawLine.Trim();
+ if (line.Length == 0 || line[0] == '#') continue;
+ signals.Add(SignalSerializer.Deserialize(line));
+ }
+ return signals;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClock.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClock.cs
new file mode 100644
index 00000000..6d44d97c
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClock.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.DecisionCore.Engine;
+
+namespace AutopilotMonitor.DecisionCore.Tests.Harness
+{
+ ///
+ /// Deterministic for reducer / harness / scenario tests.
+ /// Plan §2.6 — replay advances the clock to signal.OccurredAtUtc before each
+ /// reduce call; every delay is instant. Never used at runtime.
+ ///
+ public sealed class VirtualClock : IClock
+ {
+ private DateTime _now;
+
+ public VirtualClock(DateTime initialUtcNow)
+ {
+ if (initialUtcNow.Kind != DateTimeKind.Utc && initialUtcNow.Kind != DateTimeKind.Unspecified)
+ {
+ throw new ArgumentException("VirtualClock expects UTC/unspecified DateTime.", nameof(initialUtcNow));
+ }
+ _now = DateTime.SpecifyKind(initialUtcNow, DateTimeKind.Utc);
+ }
+
+ public DateTime UtcNow => _now;
+
+ public Task Delay(TimeSpan delay, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Advance(delay);
+ return Task.CompletedTask;
+ }
+
+ public void SetUtcNow(DateTime utcNow)
+ {
+ utcNow = DateTime.SpecifyKind(utcNow, DateTimeKind.Utc);
+ if (utcNow < _now)
+ {
+ throw new InvalidOperationException(
+ $"VirtualClock cannot go backwards (current={_now:O}, requested={utcNow:O}).");
+ }
+ _now = utcNow;
+ }
+
+ public void Advance(TimeSpan delta)
+ {
+ if (delta < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(delta), "Advance delta must be non-negative.");
+ }
+ _now = _now.Add(delta);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClockTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClockTests.cs
new file mode 100644
index 00000000..9db44273
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClockTests.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests.Harness
+{
+ public sealed class VirtualClockTests
+ {
+ [Fact]
+ public void UtcNow_reflectsInitialValue()
+ {
+ var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+ var clock = new VirtualClock(t0);
+ Assert.Equal(t0, clock.UtcNow);
+ }
+
+ [Fact]
+ public void Advance_increasesUtcNow()
+ {
+ var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+ var clock = new VirtualClock(t0);
+
+ clock.Advance(TimeSpan.FromMinutes(5));
+
+ Assert.Equal(t0.AddMinutes(5), clock.UtcNow);
+ }
+
+ [Fact]
+ public void Advance_negative_throws()
+ {
+ var clock = new VirtualClock(DateTime.UtcNow);
+ Assert.Throws(() => clock.Advance(TimeSpan.FromMinutes(-1)));
+ }
+
+ [Fact]
+ public void SetUtcNow_backwards_throws()
+ {
+ var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+ var clock = new VirtualClock(t0);
+ Assert.Throws(() => clock.SetUtcNow(t0.AddMinutes(-1)));
+ }
+
+ [Fact]
+ public async Task Delay_completesImmediately_AndAdvancesClock()
+ {
+ var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+ var clock = new VirtualClock(t0);
+ var expectedNow = t0.AddHours(7);
+
+ // In real wall-clock this would take 7 hours; VirtualClock returns instantly.
+ var start = DateTime.UtcNow;
+ await clock.Delay(TimeSpan.FromHours(7));
+ var elapsed = DateTime.UtcNow - start;
+
+ Assert.Equal(expectedNow, clock.UtcNow);
+ Assert.True(elapsed < TimeSpan.FromSeconds(1),
+ $"VirtualClock.Delay should be instant, took {elapsed.TotalMilliseconds} ms.");
+ }
+
+ [Fact]
+ public async Task Delay_withCancelledToken_throwsOperationCanceled()
+ {
+ var clock = new VirtualClock(DateTime.UtcNow);
+ using (var cts = new CancellationTokenSource())
+ {
+ cts.Cancel();
+ await Assert.ThrowsAsync(() =>
+ clock.Delay(TimeSpan.FromSeconds(1), cts.Token));
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs
index 6a1cfc4c..6060bc30 100644
--- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs
@@ -62,11 +62,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
}
[Fact]
- public void Replay_nonEmptyStream_M2Stub_throwsViaReducer()
+ public void Replay_singleSessionStartedSignal_recordsOneTakenTransition()
{
- // M1 stub: DecisionEngine.Reduce throws NotImplementedException.
- // The harness should surface that faithfully — no silent swallowing.
- // M3 replaces this test's expectation with real terminal-stage assertions.
var harness = new ReplayHarness(new DecisionEngine());
var signal = new DecisionSignal(
sessionSignalOrdinal: 0,
@@ -77,8 +74,36 @@ namespace AutopilotMonitor.DecisionCore.Tests
sourceOrigin: "harness",
evidence: new Evidence(EvidenceKind.Synthetic, "session:started", "init"));
- Assert.Throws(() =>
- harness.Replay("s", "t", new List { signal }));
+ var result = harness.Replay("s", "t", new List { signal });
+
+ Assert.Single(result.Transitions);
+ Assert.True(result.Transitions[0].Taken);
+ Assert.Equal(SessionStage.SessionStarted, result.FinalState.Stage);
+ Assert.Equal(1, result.FinalState.StepIndex);
+ Assert.Equal(0, result.FinalState.LastAppliedSignalOrdinal);
+ }
+
+ [Fact]
+ public void Replay_unknownSignalKind_recordsDeadEnd_stateUnchanged()
+ {
+ // An unhandled signal advances bookkeeping but leaves stage/outcome alone
+ // and records a DeadEndReason — plan §2.5 fail-safe shape for M3.0 non-handlers.
+ var harness = new ReplayHarness(new DecisionEngine());
+ var signal = new DecisionSignal(
+ sessionSignalOrdinal: 0,
+ sessionTraceOrdinal: 0,
+ kind: DecisionSignalKind.EspPhaseChanged, // not wired until M3.1
+ kindSchemaVersion: 1,
+ occurredAtUtc: DateTime.UtcNow,
+ sourceOrigin: "harness",
+ evidence: new Evidence(EvidenceKind.Raw, "ESP:test", "test"));
+
+ var result = harness.Replay("s", "t", new List { signal });
+
+ var t = Assert.Single(result.Transitions);
+ Assert.False(t.Taken);
+ Assert.StartsWith("unhandled_signal_kind", t.DeadEndReason);
+ Assert.Equal(SessionStage.SessionStarted, result.FinalState.Stage);
}
}
}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs
new file mode 100644
index 00000000..1ef8eb7d
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs
@@ -0,0 +1,120 @@
+using System;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests
+{
+ ///
+ /// Plan §2.5 L.16 — the reducer kernel catches handler exceptions, records them as
+ /// DeadEndReason="reducer_exception" transitions, and leaves state unchanged
+ /// except for bookkeeping. M3.0 has no handlers that throw naturally; we verify the
+ /// kernel's fail-safe path by forcing a handler exception through a broken state.
+ ///
+ public sealed class ReducerFailSafeTests
+ {
+ [Fact]
+ public void Reduce_nullState_throws_ArgumentNullException()
+ {
+ // This is a programmer-error path — distinct from reducer-internal exceptions.
+ var engine = new DecisionEngine();
+ Assert.Throws(() =>
+ engine.Reduce(null!, MakeSignal(DecisionSignalKind.SessionStarted)));
+ }
+
+ [Fact]
+ public void Reduce_nullSignal_throws_ArgumentNullException()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ Assert.Throws(() => engine.Reduce(state, null!));
+ }
+
+ [Fact]
+ public void Reduce_sessionStartedInActiveState_producesDeadEnd_stateUnchanged()
+ {
+ // Build a state that is NOT at StepIndex 0 & SessionStarted — simulates mid-flight replay.
+ var engine = new DecisionEngine();
+ var active = DecisionState.CreateInitial("s", "t")
+ .ToBuilder()
+ .WithStage(SessionStage.AwaitingHello)
+ .WithStepIndex(5)
+ .WithLastAppliedSignalOrdinal(4)
+ .Build();
+
+ var signal = MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 5);
+
+ var step = engine.Reduce(active, signal);
+
+ Assert.False(step.Transition.Taken);
+ Assert.StartsWith("session_started_in_active_state", step.Transition.DeadEndReason);
+ Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
+ Assert.Equal(6, step.NewState.StepIndex);
+ Assert.Equal(5, step.NewState.LastAppliedSignalOrdinal);
+ }
+
+ [Fact]
+ public void Reduce_unhandledKind_producesDeadEnd_withVersionedReason()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ var signal = MakeSignal(DecisionSignalKind.EspPhaseChanged, schemaVersion: 1);
+
+ var step = engine.Reduce(state, signal);
+
+ Assert.False(step.Transition.Taken);
+ Assert.Equal("unhandled_signal_kind:EspPhaseChanged:v1", step.Transition.DeadEndReason);
+ Assert.Equal(SessionStage.SessionStarted, step.NewState.Stage);
+ Assert.Equal(1, step.NewState.StepIndex);
+ }
+
+ [Fact]
+ public void Reduce_sessionAborted_terminatesWithAbortedOutcome_clearsDeadlines()
+ {
+ var engine = new DecisionEngine();
+ var withDeadline = DecisionState.CreateInitial("s", "t")
+ .ToBuilder()
+ .AddDeadline(new ActiveDeadline(
+ name: "hello_safety",
+ dueAtUtc: DateTime.UtcNow.AddMinutes(5),
+ firesSignalKind: DecisionSignalKind.DeadlineFired))
+ .WithStage(SessionStage.AwaitingHello)
+ .Build();
+
+ var step = engine.Reduce(withDeadline, MakeSignal(DecisionSignalKind.SessionAborted));
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+ Assert.Equal(SessionOutcome.Aborted, step.NewState.Outcome);
+ Assert.Empty(step.NewState.Deadlines);
+ Assert.True(step.Transition.Taken);
+ }
+
+ [Fact]
+ public void ReducerVersion_matchesAssemblyVersion_stableAcrossReduces()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+
+ var step1 = engine.Reduce(state, MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 0));
+ var step2 = engine.Reduce(step1.NewState, MakeSignal(DecisionSignalKind.SessionRecovered, ordinal: 1));
+
+ Assert.Equal("2.0.0.0", engine.ReducerVersion);
+ Assert.Equal("2.0.0.0", step1.Transition.ReducerVersion);
+ Assert.Equal("2.0.0.0", step2.Transition.ReducerVersion);
+ }
+
+ private static DecisionSignal MakeSignal(
+ DecisionSignalKind kind,
+ long ordinal = 0,
+ int schemaVersion = 1) =>
+ new DecisionSignal(
+ sessionSignalOrdinal: ordinal,
+ sessionTraceOrdinal: ordinal,
+ kind: kind,
+ kindSchemaVersion: schemaVersion,
+ occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc),
+ sourceOrigin: "test",
+ evidence: new Evidence(EvidenceKind.Synthetic, $"test:{kind}", "test"));
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs
new file mode 100644
index 00000000..b5e68ddd
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Serialization;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
+using Newtonsoft.Json;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests
+{
+ public sealed class SerializationTests
+ {
+ [Fact]
+ public void SignalSerializer_roundtrip_preservesAllFields()
+ {
+ var original = new DecisionSignal(
+ sessionSignalOrdinal: 42,
+ sessionTraceOrdinal: 99,
+ kind: DecisionSignalKind.EspPhaseChanged,
+ kindSchemaVersion: 1,
+ occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc),
+ sourceOrigin: "EspAndHelloTracker",
+ evidence: new Evidence(
+ kind: EvidenceKind.Derived,
+ identifier: "esp-phase-detector-v1",
+ summary: "AccountSetup observed in registry",
+ rawPointer: "blob://events/42",
+ derivationInputs: new Dictionary
+ {
+ ["registryKey"] = "HKLM\\...Autopilot\\EspStatus",
+ ["rawValue"] = "2",
+ }),
+ payload: new Dictionary
+ {
+ ["phase"] = "AccountSetup",
+ });
+
+ var json = SignalSerializer.Serialize(original);
+ var roundtripped = SignalSerializer.Deserialize(json);
+
+ Assert.Equal(original.SessionSignalOrdinal, roundtripped.SessionSignalOrdinal);
+ Assert.Equal(original.SessionTraceOrdinal, roundtripped.SessionTraceOrdinal);
+ Assert.Equal(original.Kind, roundtripped.Kind);
+ Assert.Equal(original.KindSchemaVersion, roundtripped.KindSchemaVersion);
+ Assert.Equal(original.OccurredAtUtc, roundtripped.OccurredAtUtc);
+ Assert.Equal(DateTimeKind.Utc, roundtripped.OccurredAtUtc.Kind);
+ Assert.Equal(original.SourceOrigin, roundtripped.SourceOrigin);
+ Assert.Equal(original.Evidence.Kind, roundtripped.Evidence.Kind);
+ Assert.Equal(original.Evidence.Identifier, roundtripped.Evidence.Identifier);
+ Assert.Equal(original.Evidence.Summary, roundtripped.Evidence.Summary);
+ Assert.Equal(original.Evidence.RawPointer, roundtripped.Evidence.RawPointer);
+ Assert.Equal("HKLM\\...Autopilot\\EspStatus", roundtripped.Evidence.DerivationInputs!["registryKey"]);
+ Assert.Equal("AccountSetup", roundtripped.Payload!["phase"]);
+ }
+
+ [Fact]
+ public void SignalSerializer_Deserialize_MissingEvidence_throws()
+ {
+ var json = "{\"SessionSignalOrdinal\":0,\"Kind\":\"SessionStarted\",\"KindSchemaVersion\":1,\"OccurredAtUtc\":\"2026-04-20T10:00:00Z\",\"SourceOrigin\":\"test\"}";
+ Assert.Throws(() => SignalSerializer.Deserialize(json));
+ }
+
+ [Fact]
+ public void UnknownFallbackEnumConverter_unknownValue_mapsToFallback()
+ {
+ // An old backend reading a row produced by a newer DecisionCore that added a
+ // new Stage value must not crash — it reads it as Unknown instead.
+ var settings = DecisionCoreJsonSettings.Create();
+ var json = "\"NeueUnbekannteStage\"";
+
+ var result = JsonConvert.DeserializeObject(json, settings);
+
+ Assert.Equal(SessionStage.Unknown, result);
+ }
+
+ [Fact]
+ public void UnknownFallbackEnumConverter_knownValue_roundtripsByName()
+ {
+ var settings = DecisionCoreJsonSettings.Create();
+ var original = SessionStage.AwaitingHello;
+
+ var json = JsonConvert.SerializeObject(original, settings);
+ var roundtripped = JsonConvert.DeserializeObject(json, settings);
+
+ Assert.Contains("AwaitingHello", json);
+ Assert.Equal(original, roundtripped);
+ }
+
+ [Fact]
+ public void UnknownFallbackEnumConverter_legacyNumericValue_mapsIfDefined()
+ {
+ var settings = DecisionCoreJsonSettings.Create();
+ // EnrollmentPhase.AccountSetup is integer value 4.
+ var json = "4";
+
+ var result = JsonConvert.DeserializeObject(json, settings);
+
+ Assert.Equal(EnrollmentPhase.AccountSetup, result);
+ }
+
+ [Fact]
+ public void UnknownFallbackEnumConverter_outOfRangeNumeric_mapsToFallback()
+ {
+ var settings = DecisionCoreJsonSettings.Create();
+ var json = "9999";
+
+ var result = JsonConvert.DeserializeObject(json, settings);
+
+ Assert.Equal(HypothesisLevel.Unknown, result);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs
index 0cebb4df..d3dc23a9 100644
--- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs
@@ -67,8 +67,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
}
[Fact]
- public void DecisionEngine_Reduce_M1Stub_throwsNotImplemented()
+ public void DecisionEngine_Reduce_SessionStarted_advancesStep()
{
+ // M3.0: Reduce is no longer a stub; SessionStarted handler advances StepIndex
+ // and records a taken transition.
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var signal = new DecisionSignal(
@@ -80,7 +82,14 @@ namespace AutopilotMonitor.DecisionCore.Tests
sourceOrigin: "test",
evidence: new Evidence(EvidenceKind.Synthetic, "session:started", "test"));
- Assert.Throws(() => engine.Reduce(state, signal));
+ var step = engine.Reduce(state, signal);
+
+ Assert.Equal(1, step.NewState.StepIndex);
+ Assert.Equal(0, step.NewState.LastAppliedSignalOrdinal);
+ Assert.True(step.Transition.Taken);
+ Assert.Equal("SessionStarted", step.Transition.Trigger);
+ Assert.Null(step.Transition.DeadEndReason);
+ Assert.Empty(step.Effects);
}
[Fact]
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj b/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj
index bd34b6db..bc6827a3 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj
+++ b/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj
@@ -18,4 +18,9 @@
+
+
+
+
+
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
new file mode 100644
index 00000000..2a8c2b23
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ // Lifecycle + cross-scenario shared handlers. Plan §2.5 partial-class layout.
+ public sealed partial class DecisionEngine
+ {
+ ///
+ /// Handle . Plan §2.7.
+ ///
+ /// For a fresh session this is the first signal and the state already equals
+ /// . The handler still runs
+ /// through the pipeline so the start is recorded as a journal transition (step 0) —
+ /// this anchors the Inspector timeline.
+ ///
+ ///
+ /// If the engine sees SessionStarted on a state whose stage is already
+ /// something other than , we treat it as a
+ /// defensive no-op (dead-end) rather than silently reinitializing — replay of a
+ /// truncated log should fail visibly, not reset hard-won hypotheses.
+ ///
+ ///
+ private DecisionStep HandleSessionStartedV1(DecisionState state, DecisionSignal signal)
+ {
+ if (state.Stage != SessionStage.SessionStarted && state.StepIndex != 0)
+ {
+ var bookkeptDead = BumpStepBookkeeping(state, signal);
+ return new DecisionStep(
+ bookkeptDead,
+ BuildDeadEndTransition(
+ state: state,
+ signal: signal,
+ nextStepIndex: bookkeptDead.StepIndex,
+ trigger: nameof(DecisionSignalKind.SessionStarted),
+ deadEndReason: $"session_started_in_active_state:{state.Stage}"),
+ Array.Empty());
+ }
+
+ // Stage stays SessionStarted; this transition is the "we saw the start" anchor.
+ var newState = state.ToBuilder()
+ .WithStage(SessionStage.SessionStarted)
+ .WithStepIndex(state.StepIndex + 1)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
+ .Build();
+
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: SessionStage.SessionStarted,
+ nextStepIndex: newState.StepIndex,
+ trigger: nameof(DecisionSignalKind.SessionStarted));
+
+ return new DecisionStep(newState, transition, Array.Empty());
+ }
+
+ ///
+ /// Handle . Plan §2.7 sonder-case 1.
+ ///
+ /// In M3.0 scope this is a generic bookkeeping handler. The White-Glove Part-1 →
+ /// Part-2 post-reboot transition is implemented in DecisionEngine.WhiteGlovePart2.cs
+ /// (M3.4) and takes precedence there when the prior stage was
+ /// .
+ ///
+ ///
+ private DecisionStep HandleSessionRecoveredV1(DecisionState state, DecisionSignal signal)
+ {
+ // M3.4 will add WhiteGlove-Part-2 transition here. For M3.0 the handler is a
+ // neutral "observed a restart" step — stage unchanged, StepIndex advanced.
+ var newState = BumpStepBookkeeping(state, signal);
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: newState.StepIndex,
+ trigger: nameof(DecisionSignalKind.SessionRecovered));
+
+ return new DecisionStep(newState, transition, Array.Empty());
+ }
+
+ ///
+ /// Handle .
+ ///
+ /// Emitted by the orchestrator, never by a collector. Stage transitions to
+ /// with .
+ /// This is a terminal event; the orchestrator uses it to record admin-kill /
+ /// override actions cleanly without going through the regular completion paths
+ /// (plan §2.7 admin-action audit).
+ ///
+ ///
+ private DecisionStep HandleSessionAbortedV1(DecisionState state, DecisionSignal signal)
+ {
+ var nextStep = state.StepIndex + 1;
+ var newState = state.ToBuilder()
+ .WithStage(SessionStage.Failed)
+ .WithOutcome(SessionOutcome.Aborted)
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
+ .ClearDeadlines()
+ .Build();
+
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: SessionStage.Failed,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.SessionAborted));
+
+ return new DecisionStep(newState, transition, Array.Empty());
+ }
+
+ // ============================================================== shared helpers
+ // Partial-class shared helpers used by Classic / SelfDeploying / WhiteGlove handlers
+ // as they come online in M3.1+ live below. M3.0 establishes the skeleton; the bodies
+ // grow with each sub-milestone.
+
+ ///
+ /// Determine the user-visible enrollment phase implied by an ESP phase-change signal.
+ /// Plan §2.3 phase-fact mapping. Populated in M3.1 as Classic handlers come online.
+ ///
+ internal static EnrollmentPhase MapEspPhaseToEnrollmentPhase(string rawPhase)
+ {
+ if (string.IsNullOrEmpty(rawPhase)) return EnrollmentPhase.Unknown;
+ return rawPhase switch
+ {
+ "DeviceSetup" => EnrollmentPhase.DeviceSetup,
+ "AccountSetup" => EnrollmentPhase.AccountSetup,
+ "FinalizingSetup" => EnrollmentPhase.FinalizingSetup,
+ "Finalizing" => EnrollmentPhase.FinalizingSetup,
+ "Complete" => EnrollmentPhase.Complete,
+ _ => EnrollmentPhase.Unknown,
+ };
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
index d88f68ee..3bf188d8 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
@@ -1,23 +1,27 @@
using System;
+using System.Collections.Generic;
using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Engine
{
///
- /// Kernel stub for the Decision Engine. Plan §2.5 + M1 gate.
+ /// Decision Engine kernel. Plan §2.5 / L.2 / L.3.
///
- /// M1 scaffolding — no handler dispatch implemented. throws
- /// so that any accidental runtime call from
- /// the Legacy-Agent or Backend fails loudly during the scaffolding phase (plan:
- /// "Agent + Backend referenzieren, ohne Warnings, keine Call-Sites").
+ /// Dispatches on (, )
+ /// to partial-class handlers. All handlers live in sibling files named
+ /// DecisionEngine.Classic.cs, DecisionEngine.SelfDeploying.cs,
+ /// DecisionEngine.WhiteGlove.cs, DecisionEngine.WhiteGlovePart2.cs,
+ /// DecisionEngine.Shared.cs.
///
///
- /// M3 replaces this body with the partial-class dispatcher
- /// (DecisionEngine.Classic.cs, .SelfDeploying.cs, .WhiteGlove.cs,
- /// .WhiteGlovePart2.cs, .Shared.cs) and the dispatch table is keyed on
- /// (DecisionSignalKind, KindSchemaVersion).
+ /// Exception fail-safe (L.16): a handler exception never corrupts state; the kernel
+ /// catches it, advances , applies the signal ordinal,
+ /// and emits a dead-end tagged
+ /// DeadEndReason="reducer_exception".
///
///
public sealed partial class DecisionEngine : IDecisionEngine
@@ -29,19 +33,164 @@ namespace AutopilotMonitor.DecisionCore.Engine
public DecisionStep Reduce(DecisionState oldState, DecisionSignal signal)
{
- if (oldState == null)
- {
- throw new ArgumentNullException(nameof(oldState));
- }
+ if (oldState == null) throw new ArgumentNullException(nameof(oldState));
+ if (signal == null) throw new ArgumentNullException(nameof(signal));
- if (signal == null)
+ try
{
- throw new ArgumentNullException(nameof(signal));
+ return Dispatch(oldState, signal);
}
+ catch (Exception ex)
+ {
+ return BuildReducerExceptionStep(oldState, signal, ex);
+ }
+ }
- throw new NotImplementedException(
- "DecisionEngine.Reduce is a scaffolding stub (M1). " +
- "Handler implementation lands in M3 (plan §2.5).");
+ ///
+ /// Pure dispatch on (Kind, SchemaVersion) — no mutation here, no try/catch
+ /// (outer provides the fail-safe wrapper). Handlers return fully
+ /// constructed instances.
+ ///
+ private DecisionStep Dispatch(DecisionState state, DecisionSignal signal)
+ {
+ return (signal.Kind, signal.KindSchemaVersion) switch
+ {
+ // ----- Lifecycle (DecisionEngine.Shared.cs) -----
+ (DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
+ (DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
+ (DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
+
+ // ----- All other kinds (M3.1+) — fall through to Unhandled for M3.0 scope. -----
+ _ => HandleUnhandledSignal(state, signal),
+ };
+ }
+
+ // ================================================================== helpers
+
+ ///
+ /// Advance StepIndex / LastAppliedSignalOrdinal without touching the engine-visible
+ /// stage or facts. Reducer handlers call this when they record a step but don't
+ /// progress the state machine (e.g. hypothesis-only update, or dead-end).
+ ///
+ internal DecisionState BumpStepBookkeeping(DecisionState state, DecisionSignal signal) =>
+ state.ToBuilder()
+ .WithStepIndex(state.StepIndex + 1)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
+ .Build();
+
+ ///
+ /// Build a that records a state-changing step. Guards
+ /// default to empty (for handler-initiated changes that aren't guard-driven); callers
+ /// pass explicit guard evaluations when they matter to the Inspector.
+ ///
+ internal DecisionTransition BuildTakenTransition(
+ DecisionState before,
+ DecisionSignal signal,
+ SessionStage toStage,
+ int nextStepIndex,
+ string trigger,
+ IReadOnlyList? guards = null)
+ {
+ return new DecisionTransition(
+ stepIndex: nextStepIndex,
+ sessionTraceOrdinal: signal.SessionTraceOrdinal,
+ signalOrdinalRef: signal.SessionSignalOrdinal,
+ occurredAtUtc: signal.OccurredAtUtc,
+ trigger: trigger,
+ fromStage: before.Stage,
+ toStage: toStage,
+ taken: true,
+ deadEndReason: null,
+ reducerVersion: ReducerVersion,
+ guards: guards,
+ emittedEventSequences: null,
+ classifierVerdict: null,
+ errorMessage: null,
+ stackTraceHash: null);
+ }
+
+ internal DecisionTransition BuildDeadEndTransition(
+ DecisionState state,
+ DecisionSignal signal,
+ int nextStepIndex,
+ string trigger,
+ string deadEndReason,
+ IReadOnlyList? guards = null)
+ {
+ return new DecisionTransition(
+ stepIndex: nextStepIndex,
+ sessionTraceOrdinal: signal.SessionTraceOrdinal,
+ signalOrdinalRef: signal.SessionSignalOrdinal,
+ occurredAtUtc: signal.OccurredAtUtc,
+ trigger: trigger,
+ fromStage: state.Stage,
+ toStage: state.Stage,
+ taken: false,
+ deadEndReason: deadEndReason,
+ reducerVersion: ReducerVersion,
+ guards: guards,
+ emittedEventSequences: null,
+ classifierVerdict: null,
+ errorMessage: null,
+ stackTraceHash: null);
+ }
+
+ ///
+ /// Plan §2.5 L.16 — exception fail-safe. State is unchanged (no builder copy); only
+ /// bookkeeping (StepIndex, LastAppliedSignalOrdinal) advances so the signal counts as
+ /// processed. holds the original message
+ /// and a short SHA256 of the trace —
+ /// enough for an Inspector to link recurring-exception rows without dumping full PII.
+ ///
+ private DecisionStep BuildReducerExceptionStep(
+ DecisionState state,
+ DecisionSignal signal,
+ Exception exception)
+ {
+ var bookkept = BumpStepBookkeeping(state, signal);
+ var transition = new DecisionTransition(
+ stepIndex: bookkept.StepIndex,
+ sessionTraceOrdinal: signal.SessionTraceOrdinal,
+ signalOrdinalRef: signal.SessionSignalOrdinal,
+ occurredAtUtc: signal.OccurredAtUtc,
+ trigger: signal.Kind.ToString(),
+ fromStage: state.Stage,
+ toStage: state.Stage,
+ taken: false,
+ deadEndReason: "reducer_exception",
+ reducerVersion: ReducerVersion,
+ guards: null,
+ emittedEventSequences: null,
+ classifierVerdict: null,
+ errorMessage: exception.Message,
+ stackTraceHash: HashStackTrace(exception));
+
+ return new DecisionStep(bookkept, transition, Array.Empty());
+ }
+
+ private DecisionStep HandleUnhandledSignal(DecisionState state, DecisionSignal signal)
+ {
+ var bookkept = BumpStepBookkeeping(state, signal);
+ var transition = BuildDeadEndTransition(
+ state: state,
+ signal: signal,
+ nextStepIndex: bookkept.StepIndex,
+ trigger: signal.Kind.ToString(),
+ deadEndReason: $"unhandled_signal_kind:{signal.Kind}:v{signal.KindSchemaVersion}");
+
+ return new DecisionStep(bookkept, transition, Array.Empty());
+ }
+
+ private static string HashStackTrace(Exception exception)
+ {
+ var raw = exception.StackTrace ?? exception.GetType().FullName ?? "unknown";
+ using (var sha = SHA256.Create())
+ {
+ var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(raw));
+ var sb = new StringBuilder(16);
+ for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2"));
+ return sb.ToString();
+ }
}
}
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/IClock.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/IClock.cs
new file mode 100644
index 00000000..fed19cf0
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/IClock.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ ///
+ /// Pluggable clock abstraction. Plan §2.6 L.7.
+ ///
+ /// Time is consumed exclusively through this interface by every time-dependent
+ /// component in DecisionCore and Agent.V2.Core (Deadline-Scheduler,
+ /// ClassifierTick-Timer, recovery logic, …). Agent runtime uses ;
+ /// the replay harness uses VirtualClock so 420-second timeouts run in
+ /// milliseconds and are deterministic.
+ ///
+ ///
+ public interface IClock
+ {
+ /// Current UTC time.
+ DateTime UtcNow { get; }
+
+ /// Async delay; virtualized in tests.
+ Task Delay(TimeSpan delay, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/SystemClock.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/SystemClock.cs
new file mode 100644
index 00000000..6d6ecba1
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/SystemClock.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ ///
+ /// Default implementation for the agent runtime — wraps
+ /// and .
+ /// Tests must use a virtual clock implementation, never this one.
+ ///
+ public sealed class SystemClock : IClock
+ {
+ public static readonly SystemClock Instance = new SystemClock();
+
+ public DateTime UtcNow => DateTime.UtcNow;
+
+ public Task Delay(TimeSpan delay, CancellationToken cancellationToken = default) =>
+ Task.Delay(delay, cancellationToken);
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs
new file mode 100644
index 00000000..de7e539d
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs
@@ -0,0 +1,57 @@
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.DecisionCore.Serialization
+{
+ ///
+ /// Centralized factory for DecisionCore DTOs.
+ /// Plan §2.8 / §2.15 L.14.
+ ///
+ /// Produces a instance with:
+ ///
+ /// - All DecisionCore enums registered via
+ /// so unknown values land on Unknown instead of throwing.
+ /// - — no polymorphism loopholes over the wire.
+ /// - — preserve nullable fact/hypothesis fields.
+ /// - UTC round-trip for DateTime — ,
+ /// ISO-8601 format.
+ ///
+ ///
+ ///
+ public static class DecisionCoreJsonSettings
+ {
+ ///
+ /// Create a fresh settings instance. Not a static singleton — Newtonsoft mutates
+ /// internal state during serialization (binder caches, etc.), so handing out
+ /// shared instances across threads is a well-known footgun.
+ ///
+ public static JsonSerializerSettings Create()
+ {
+ var settings = new JsonSerializerSettings
+ {
+ TypeNameHandling = TypeNameHandling.None,
+ NullValueHandling = NullValueHandling.Include,
+ DateTimeZoneHandling = DateTimeZoneHandling.Utc,
+ DateParseHandling = DateParseHandling.DateTime,
+ Formatting = Formatting.None,
+ MissingMemberHandling = MissingMemberHandling.Ignore,
+ };
+
+ // Enum converters with Unknown-fallback — plan §2.15 L.14.
+ settings.Converters.Add(new UnknownFallbackEnumConverter(DecisionSignalKind.SessionStarted));
+ // ^ SessionStarted is a neutral fallback; unknown signal kinds should be rare (SchemaVersion gate),
+ // and when they do occur we prefer a benign no-op over a crash.
+ settings.Converters.Add(new UnknownFallbackEnumConverter(EvidenceKind.Synthetic));
+ settings.Converters.Add(new UnknownFallbackEnumConverter(HypothesisLevel.Unknown));
+ settings.Converters.Add(new UnknownFallbackEnumConverter(SessionStage.Unknown));
+ settings.Converters.Add(new UnknownFallbackEnumConverter(SessionOutcome.Unknown));
+ settings.Converters.Add(new UnknownFallbackEnumConverter(DecisionEffectKind.PersistSnapshot));
+ settings.Converters.Add(new UnknownFallbackEnumConverter(EnrollmentPhase.Unknown));
+
+ return settings;
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs
new file mode 100644
index 00000000..14990a02
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Signals;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace AutopilotMonitor.DecisionCore.Serialization
+{
+ ///
+ /// Serialize / deserialize JSONL records.
+ /// Plan §2.7 / §4 M2 harness integration.
+ ///
+ /// Uses Newtonsoft.Json with . The reader
+ /// tolerates missing optional fields but enforces the evidence-kind mandatory-field
+ /// contract (plan §2.2) — a line that would fail new Evidence(...) also fails
+ /// here with the same .
+ ///
+ ///
+ public static class SignalSerializer
+ {
+ public static string Serialize(DecisionSignal signal)
+ {
+ if (signal == null) throw new ArgumentNullException(nameof(signal));
+ return JsonConvert.SerializeObject(signal, DecisionCoreJsonSettings.Create());
+ }
+
+ public static DecisionSignal Deserialize(string line)
+ {
+ if (line == null) throw new ArgumentNullException(nameof(line));
+
+ var settings = DecisionCoreJsonSettings.Create();
+ var obj = JsonConvert.DeserializeObject(line, settings)
+ ?? throw new JsonReaderException($"Failed to parse line as JSON object: {line}");
+
+ // Evidence is required.
+ var evidenceTok = obj["Evidence"] ?? throw new JsonSerializationException("Signal is missing Evidence block.");
+ var evidenceKind = evidenceTok.Value("Kind") ?? throw new JsonSerializationException("Evidence.Kind missing.");
+ var evidenceIdentifier = evidenceTok.Value("Identifier")
+ ?? throw new JsonSerializationException("Evidence.Identifier missing.");
+ var evidenceSummary = evidenceTok.Value("Summary") ?? string.Empty;
+ var rawPointer = evidenceTok.Value("RawPointer");
+
+ IReadOnlyDictionary? derivationInputs = null;
+ if (evidenceTok["DerivationInputs"] is JObject diObj)
+ {
+ var d = new Dictionary();
+ foreach (var prop in diObj.Properties())
+ {
+ d[prop.Name] = prop.Value?.ToString() ?? string.Empty;
+ }
+ derivationInputs = d;
+ }
+
+ var evidence = new Evidence(
+ kind: ParseEnum(evidenceKind, EvidenceKind.Synthetic),
+ identifier: evidenceIdentifier,
+ summary: evidenceSummary,
+ rawPointer: rawPointer,
+ derivationInputs: derivationInputs);
+
+ IReadOnlyDictionary? payload = null;
+ if (obj["Payload"] is JObject payloadObj)
+ {
+ var p = new Dictionary();
+ foreach (var prop in payloadObj.Properties())
+ {
+ p[prop.Name] = prop.Value?.ToString() ?? string.Empty;
+ }
+ payload = p;
+ }
+
+ return new DecisionSignal(
+ sessionSignalOrdinal: obj.Value("SessionSignalOrdinal"),
+ sessionTraceOrdinal: obj.Value("SessionTraceOrdinal"),
+ kind: ParseEnum(obj.Value("Kind"), DecisionSignalKind.SessionStarted),
+ kindSchemaVersion: obj.Value("KindSchemaVersion") ?? 1,
+ occurredAtUtc: obj.Value("OccurredAtUtc"),
+ sourceOrigin: obj.Value("SourceOrigin") ?? "unknown",
+ evidence: evidence,
+ payload: payload);
+ }
+
+ private static T ParseEnum(string? raw, T fallback) where T : struct, Enum
+ {
+ if (string.IsNullOrEmpty(raw)) return fallback;
+ return Enum.TryParse(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(typeof(T), parsed)
+ ? parsed
+ : fallback;
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs
new file mode 100644
index 00000000..c807e3b4
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs
@@ -0,0 +1,82 @@
+using System;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.DecisionCore.Serialization
+{
+ ///
+ /// Forward-compatibility converter for enums in DecisionCore DTOs. Plan §2.15 L.14.
+ ///
+ /// Serializes as its name.
+ /// Deserialization maps unknown / unrecognized string values to a caller-provided
+ /// default — typically Unknown — instead of throwing. This lets an older
+ /// consumer (e.g. a backend built against an older DecisionCore version) read rows
+ /// written by a newer producer (new enum values) without crashing; the consumer
+ /// flags those rows as VerificationStatus=drift_tolerated per plan §2.10.
+ ///
+ ///
+ public sealed class UnknownFallbackEnumConverter : JsonConverter
+ where T : struct, Enum
+ {
+ private readonly T _unknownFallback;
+
+ public UnknownFallbackEnumConverter(T unknownFallback)
+ {
+ _unknownFallback = unknownFallback;
+ }
+
+ public override bool CanConvert(Type objectType)
+ {
+ var t = Nullable.GetUnderlyingType(objectType) ?? objectType;
+ return t == typeof(T);
+ }
+
+ public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
+ {
+ if (value == null)
+ {
+ writer.WriteNull();
+ return;
+ }
+ writer.WriteValue(value.ToString());
+ }
+
+ public override object? ReadJson(
+ JsonReader reader,
+ Type objectType,
+ object? existingValue,
+ JsonSerializer serializer)
+ {
+ if (reader.TokenType == JsonToken.Null)
+ {
+ return Nullable.GetUnderlyingType(objectType) != null ? (object?)null : _unknownFallback;
+ }
+
+ if (reader.TokenType == JsonToken.Integer)
+ {
+ // Legacy / numeric payloads. Try to cast; fallback if out of range.
+ var numeric = Convert.ToInt32(reader.Value);
+ if (Enum.IsDefined(typeof(T), numeric))
+ {
+ return (T)Enum.ToObject(typeof(T), numeric);
+ }
+ return _unknownFallback;
+ }
+
+ if (reader.TokenType == JsonToken.String)
+ {
+ var raw = reader.Value as string;
+ if (string.IsNullOrEmpty(raw))
+ {
+ return _unknownFallback;
+ }
+ if (Enum.TryParse(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(typeof(T), parsed))
+ {
+ return parsed;
+ }
+ return _unknownFallback;
+ }
+
+ return _unknownFallback;
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
index d1a85cd2..4bd5ea8f 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
@@ -131,6 +131,13 @@ namespace AutopilotMonitor.DecisionCore.State
public string SchemaVersion { get; }
+ ///
+ /// Produce a mutable builder pre-populated with this state's values.
+ /// Reducer handlers call state.ToBuilder().WithStage(...).Build() to
+ /// express immutable "copy with changes" ergonomically (plan §2.3 / L.3).
+ ///
+ public DecisionStateBuilder ToBuilder() => new DecisionStateBuilder(this);
+
///
/// Construct the initial non-terminal state for a new session.
/// Used by the reducer's SessionStarted handler in M3.
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
new file mode 100644
index 00000000..daf0e8f5
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.DecisionCore.State
+{
+ ///
+ /// Mutable construction helper for producing a new immutable .
+ ///
+ /// Plan §2.3 / L.3 — DecisionState itself is immutable; reducer handlers use the
+ /// builder to express "copy with changes" without typing all 20+ constructor arguments.
+ /// Call to materialize a new . The original
+ /// state is never mutated.
+ ///
+ ///
+ public sealed class DecisionStateBuilder
+ {
+ public DecisionStateBuilder(DecisionState source)
+ {
+ if (source == null) throw new ArgumentNullException(nameof(source));
+
+ SessionId = source.SessionId;
+ TenantId = source.TenantId;
+ Stage = source.Stage;
+ Outcome = source.Outcome;
+ EnrollmentType = source.EnrollmentType;
+ WhiteGloveSealing = source.WhiteGloveSealing;
+ WhiteGlovePart2Completion = source.WhiteGlovePart2Completion;
+ DeviceOnlyDeployment = source.DeviceOnlyDeployment;
+ CurrentEnrollmentPhase = source.CurrentEnrollmentPhase;
+ DeviceSetupEnteredUtc = source.DeviceSetupEnteredUtc;
+ AccountSetupEnteredUtc = source.AccountSetupEnteredUtc;
+ FinalizingEnteredUtc = source.FinalizingEnteredUtc;
+ EspFinalExitUtc = source.EspFinalExitUtc;
+ DesktopArrivedUtc = source.DesktopArrivedUtc;
+ HelloResolvedUtc = source.HelloResolvedUtc;
+ SystemRebootUtc = source.SystemRebootUtc;
+ HelloOutcome = source.HelloOutcome;
+ AadJoinedWithUser = source.AadJoinedWithUser;
+ ImeMatchedPatternId = source.ImeMatchedPatternId;
+ Deadlines = new List(source.Deadlines);
+ LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
+ StepIndex = source.StepIndex;
+ SchemaVersion = source.SchemaVersion;
+ }
+
+ public string SessionId { get; set; }
+ public string TenantId { get; set; }
+ public SessionStage Stage { get; set; }
+ public SessionOutcome? Outcome { get; set; }
+ public Hypothesis EnrollmentType { get; set; }
+ public Hypothesis WhiteGloveSealing { get; set; }
+ public Hypothesis WhiteGlovePart2Completion { get; set; }
+ public Hypothesis DeviceOnlyDeployment { get; set; }
+ public SignalFact? CurrentEnrollmentPhase { get; set; }
+ public SignalFact? DeviceSetupEnteredUtc { get; set; }
+ public SignalFact? AccountSetupEnteredUtc { get; set; }
+ public SignalFact? FinalizingEnteredUtc { get; set; }
+ public SignalFact? EspFinalExitUtc { get; set; }
+ public SignalFact? DesktopArrivedUtc { get; set; }
+ public SignalFact? HelloResolvedUtc { get; set; }
+ public SignalFact? SystemRebootUtc { get; set; }
+ public SignalFact? HelloOutcome { get; set; }
+ public SignalFact? AadJoinedWithUser { get; set; }
+ public SignalFact? ImeMatchedPatternId { get; set; }
+ public List Deadlines { get; set; }
+ public long LastAppliedSignalOrdinal { get; set; }
+ public int StepIndex { get; set; }
+ public string SchemaVersion { get; set; }
+
+ // ---------- fluent helpers for the most common reducer operations ----------
+
+ public DecisionStateBuilder WithStage(SessionStage stage) { Stage = stage; return this; }
+
+ public DecisionStateBuilder WithOutcome(SessionOutcome? outcome) { Outcome = outcome; return this; }
+
+ public DecisionStateBuilder WithStepIndex(int stepIndex) { StepIndex = stepIndex; return this; }
+
+ public DecisionStateBuilder WithLastAppliedSignalOrdinal(long ordinal) { LastAppliedSignalOrdinal = ordinal; return this; }
+
+ public DecisionStateBuilder WithCurrentEnrollmentPhase(EnrollmentPhase phase, long sourceSignalOrdinal)
+ {
+ CurrentEnrollmentPhase = new SignalFact(phase, sourceSignalOrdinal);
+ return this;
+ }
+
+ public DecisionStateBuilder AddDeadline(ActiveDeadline deadline)
+ {
+ if (deadline == null) throw new ArgumentNullException(nameof(deadline));
+ // Replace-if-same-name semantic: deadlines identified by Name. Plan §2.6.
+ for (int i = 0; i < Deadlines.Count; i++)
+ {
+ if (string.Equals(Deadlines[i].Name, deadline.Name, StringComparison.Ordinal))
+ {
+ Deadlines[i] = deadline;
+ return this;
+ }
+ }
+ Deadlines.Add(deadline);
+ return this;
+ }
+
+ public DecisionStateBuilder CancelDeadline(string name)
+ {
+ if (name == null) throw new ArgumentNullException(nameof(name));
+ Deadlines.RemoveAll(d => string.Equals(d.Name, name, StringComparison.Ordinal));
+ return this;
+ }
+
+ public DecisionStateBuilder ClearDeadlines() { Deadlines.Clear(); return this; }
+
+ public DecisionState Build() =>
+ new DecisionState(
+ sessionId: SessionId,
+ tenantId: TenantId,
+ stage: Stage,
+ outcome: Outcome,
+ enrollmentType: EnrollmentType,
+ whiteGloveSealing: WhiteGloveSealing,
+ whiteGlovePart2Completion: WhiteGlovePart2Completion,
+ deviceOnlyDeployment: DeviceOnlyDeployment,
+ currentEnrollmentPhase: CurrentEnrollmentPhase,
+ deviceSetupEnteredUtc: DeviceSetupEnteredUtc,
+ accountSetupEnteredUtc: AccountSetupEnteredUtc,
+ finalizingEnteredUtc: FinalizingEnteredUtc,
+ espFinalExitUtc: EspFinalExitUtc,
+ desktopArrivedUtc: DesktopArrivedUtc,
+ helloResolvedUtc: HelloResolvedUtc,
+ systemRebootUtc: SystemRebootUtc,
+ helloOutcome: HelloOutcome,
+ aadJoinedWithUser: AadJoinedWithUser,
+ imeMatchedPatternId: ImeMatchedPatternId,
+ deadlines: Deadlines.ToArray(),
+ lastAppliedSignalOrdinal: LastAppliedSignalOrdinal,
+ stepIndex: StepIndex,
+ schemaVersion: SchemaVersion);
+ }
+}