refactor(decisioncore): engine foundation — dispatch, fail-safe, clock, serialization (M3.0)

M3.0 establishes the reducer infrastructure the scenario sub-milestones
(M3.1–M3.5) will build on. No domain-specific reducer behavior yet; only
kernel plumbing + a live SessionStarted / SessionRecovered / SessionAborted
lifecycle path.

Kernel (plan §2.5 / L.2 / L.3 / L.16):
 - DecisionEngine dispatches on (Kind, KindSchemaVersion) to partial-class
   handlers. Unhandled kinds produce a DeadEnd transition with a versioned
   reason string — never a throw, never a mutation.
 - Exception fail-safe (L.16): any handler exception is caught in the
   kernel, StepIndex + LastAppliedSignalOrdinal still advance (so the
   signal counts as processed and there is no retry loop), the transition
   records DeadEndReason="reducer_exception" + SHA256-short StackTraceHash.
   DecisionState is returned untouched beyond bookkeeping.
 - Shared helpers for Taken / DeadEnd transition construction keep
   partial-class handlers short and consistent.

State (plan §2.3 / L.3):
 - Immutable DecisionState gets a mutable DecisionStateBuilder companion —
   reducer handlers use state.ToBuilder().WithStage(...).Build() to
   express "copy with changes" without retyping 20+ ctor arguments.
 - Fluent helpers AddDeadline (replace-if-same-name), CancelDeadline,
   WithCurrentEnrollmentPhase carry the most common reducer operations.

Time (plan §2.6 / L.7):
 - IClock interface + SystemClock (wall-clock wrapper). The only way any
   decision-relevant code can observe time going forward. VirtualClock
   lives in the test assembly — never in shipping builds.

Serialization (plan §2.8 / §2.15 / L.14):
 - Newtonsoft.Json added to DecisionCore (both net48 + net8.0 consume the
   same DTOs with the same converters).
 - UnknownFallbackEnumConverter<T> maps unrecognized enum strings to a
   caller-provided fallback instead of throwing — lets an older backend
   read a newer DecisionCore's rows without crashing; the verifier will
   flag them as drift_tolerated in §2.10.
 - DecisionCoreJsonSettings registers fallback converters for every
   DecisionCore enum (DecisionSignalKind, EvidenceKind, HypothesisLevel,
   SessionStage, SessionOutcome, DecisionEffectKind, EnrollmentPhase).
 - SignalSerializer round-trips DecisionSignal JSONL with enforced
   Evidence.Identifier and Evidence.Kind — a line without Evidence throws
   just like constructor validation.

Lifecycle (DecisionEngine.Shared.cs):
 - SessionStarted: anchors the first journal transition; rejects mid-flight
   replay (StepIndex != 0) with a DeadEnd instead of silently resetting
   hypotheses.
 - SessionRecovered: neutral bookkeeping in M3.0 — WhiteGlove Part 1 → 2
   transition lands in M3.4 DecisionEngine.WhiteGlovePart2.cs.
 - SessionAborted: terminal, Outcome=Aborted, all deadlines cleared —
   orchestrator admin-action path (plan §2.7 audit).

Test coverage (37 total):
 - VirtualClockTests (6)     — monotonic clock, Delay instant-return
 - DecisionStateBuilderTests (5) — immutable semantics, deadline replace/cancel
 - SerializationTests (6)     — round-trip, UnknownFallback both string + numeric
 - ReducerFailSafeTests (6)   — null-arg, replay-on-active, unhandled kind,
                                SessionAborted clears deadlines, ReducerVersion
 - SmokeTests (7)             — smoke, updated: Reduce(SessionStarted) is a
                                real transition, not a throw
 - HarnessTests (6)           — empty-stream determinism, single-signal replay,
                                unknown-kind DeadEnd at harness level
 - VirtualClock helper (instance) covered via Harness integration

Suite totals unchanged elsewhere: 507 Legacy + 557 Functions. Full solution
builds 0 warnings, 0 errors.

Plan: plans/REFACTOR_AGENT_V2.md §4 M3 (sub-milestone M3.0 Foundation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 13:51:45 +02:00
parent 3f64b2809b
commit b6a949bc02
20 changed files with 1243 additions and 28 deletions
@@ -1 +1 @@
5
7
@@ -1 +1 @@
1047
1049
@@ -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);
}
}
}
@@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.DecisionCore.Serialization;
using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.DecisionCore.Tests.Harness
{
/// <summary>
/// Load a DecisionSignal JSONL fixture from disk. Plan §4 M2 harness integration.
/// Comment lines starting with '#' and blank lines are ignored.
/// </summary>
public static class FixtureLoader
{
public static IReadOnlyList<DecisionSignal> LoadJsonl(string path)
{
var signals = new List<DecisionSignal>();
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;
}
}
}
@@ -0,0 +1,55 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.DecisionCore.Engine;
namespace AutopilotMonitor.DecisionCore.Tests.Harness
{
/// <summary>
/// Deterministic <see cref="IClock"/> for reducer / harness / scenario tests.
/// Plan §2.6 — replay advances the clock to <c>signal.OccurredAtUtc</c> before each
/// reduce call; every delay is instant. Never used at runtime.
/// </summary>
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);
}
}
}
@@ -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<ArgumentOutOfRangeException>(() => 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<InvalidOperationException>(() => 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<OperationCanceledException>(() =>
clock.Delay(TimeSpan.FromSeconds(1), cts.Token));
}
}
}
}
@@ -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<NotImplementedException>(() =>
harness.Replay("s", "t", new List<DecisionSignal> { signal }));
var result = harness.Replay("s", "t", new List<DecisionSignal> { 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<DecisionSignal> { 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);
}
}
}
@@ -0,0 +1,120 @@
using System;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Plan §2.5 L.16 — the reducer kernel catches handler exceptions, records them as
/// <c>DeadEndReason="reducer_exception"</c> 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.
/// </summary>
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<ArgumentNullException>(() =>
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<ArgumentNullException>(() => 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"));
}
}
@@ -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<string, string>
{
["registryKey"] = "HKLM\\...Autopilot\\EspStatus",
["rawValue"] = "2",
}),
payload: new Dictionary<string, string>
{
["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<JsonSerializationException>(() => 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<SessionStage>(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<SessionStage>(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<EnrollmentPhase>(json, settings);
Assert.Equal(EnrollmentPhase.AccountSetup, result);
}
[Fact]
public void UnknownFallbackEnumConverter_outOfRangeNumeric_mapsToFallback()
{
var settings = DecisionCoreJsonSettings.Create();
var json = "9999";
var result = JsonConvert.DeserializeObject<HypothesisLevel>(json, settings);
Assert.Equal(HypothesisLevel.Unknown, result);
}
}
}
@@ -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<NotImplementedException>(() => 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]
@@ -18,4 +18,9 @@
<ProjectReference Include="..\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Plan §2.8 — Newtonsoft.Json on both sides (Agent net48 + Backend net8.0) for DecisionCore DTOs. -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
@@ -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
{
/// <summary>
/// Handle <see cref="DecisionSignalKind.SessionStarted"/>. Plan §2.7.
/// <para>
/// For a fresh session this is the first signal and the state already equals
/// <see cref="DecisionState.CreateInitial(string, string)"/>. The handler still runs
/// through the pipeline so the start is recorded as a journal transition (step 0) —
/// this anchors the Inspector timeline.
/// </para>
/// <para>
/// If the engine sees <c>SessionStarted</c> on a state whose stage is already
/// something other than <see cref="SessionStage.SessionStarted"/>, 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.
/// </para>
/// </summary>
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<DecisionEffect>());
}
// 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<DecisionEffect>());
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.SessionRecovered"/>. Plan §2.7 sonder-case 1.
/// <para>
/// In M3.0 scope this is a generic bookkeeping handler. The White-Glove Part-1 →
/// Part-2 post-reboot transition is implemented in <c>DecisionEngine.WhiteGlovePart2.cs</c>
/// (M3.4) and takes precedence there when the prior stage was
/// <see cref="SessionStage.WhiteGloveSealed"/>.
/// </para>
/// </summary>
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<DecisionEffect>());
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.SessionAborted"/>.
/// <para>
/// Emitted by the orchestrator, never by a collector. Stage transitions to
/// <see cref="SessionStage.Failed"/> with <see cref="SessionOutcome.Aborted"/>.
/// 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).
/// </para>
/// </summary>
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<DecisionEffect>());
}
// ============================================================== 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.
/// <summary>
/// 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.
/// </summary>
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,
};
}
}
}
@@ -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
{
/// <summary>
/// Kernel stub for the Decision Engine. Plan §2.5 + M1 gate.
/// Decision Engine kernel. Plan §2.5 / L.2 / L.3.
/// <para>
/// M1 scaffolding — no handler dispatch implemented. <see cref="Reduce"/> throws
/// <see cref="NotImplementedException"/> 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 <c>(<see cref="DecisionSignalKind"/>, <see cref="DecisionSignal.KindSchemaVersion"/>)</c>
/// to partial-class handlers. All handlers live in sibling files named
/// <c>DecisionEngine.Classic.cs</c>, <c>DecisionEngine.SelfDeploying.cs</c>,
/// <c>DecisionEngine.WhiteGlove.cs</c>, <c>DecisionEngine.WhiteGlovePart2.cs</c>,
/// <c>DecisionEngine.Shared.cs</c>.
/// </para>
/// <para>
/// M3 replaces this body with the partial-class dispatcher
/// (<c>DecisionEngine.Classic.cs</c>, <c>.SelfDeploying.cs</c>, <c>.WhiteGlove.cs</c>,
/// <c>.WhiteGlovePart2.cs</c>, <c>.Shared.cs</c>) and the dispatch table is keyed on
/// <c>(DecisionSignalKind, KindSchemaVersion)</c>.
/// Exception fail-safe (L.16): a handler exception never corrupts state; the kernel
/// catches it, advances <see cref="DecisionState.StepIndex"/>, applies the signal ordinal,
/// and emits a dead-end <see cref="DecisionTransition"/> tagged
/// <c>DeadEndReason="reducer_exception"</c>.
/// </para>
/// </summary>
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).");
/// <summary>
/// Pure dispatch on <c>(Kind, SchemaVersion)</c> — no mutation here, no try/catch
/// (outer <see cref="Reduce"/> provides the fail-safe wrapper). Handlers return fully
/// constructed <see cref="DecisionStep"/> instances.
/// </summary>
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
/// <summary>
/// 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).
/// </summary>
internal DecisionState BumpStepBookkeeping(DecisionState state, DecisionSignal signal) =>
state.ToBuilder()
.WithStepIndex(state.StepIndex + 1)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.Build();
/// <summary>
/// Build a <see cref="DecisionTransition"/> 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.
/// </summary>
internal DecisionTransition BuildTakenTransition(
DecisionState before,
DecisionSignal signal,
SessionStage toStage,
int nextStepIndex,
string trigger,
IReadOnlyList<GuardReport>? 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<GuardReport>? 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);
}
/// <summary>
/// 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. <see cref="DecisionTransition.ErrorMessage"/> holds the original message
/// and <see cref="DecisionTransition.StackTraceHash"/> a short SHA256 of the trace —
/// enough for an Inspector to link recurring-exception rows without dumping full PII.
/// </summary>
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<DecisionEffect>());
}
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<DecisionEffect>());
}
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();
}
}
}
}
@@ -0,0 +1,25 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace AutopilotMonitor.DecisionCore.Engine
{
/// <summary>
/// Pluggable clock abstraction. Plan §2.6 L.7.
/// <para>
/// Time is consumed exclusively through this interface by every time-dependent
/// component in <c>DecisionCore</c> and <c>Agent.V2.Core</c> (Deadline-Scheduler,
/// ClassifierTick-Timer, recovery logic, …). Agent runtime uses <see cref="SystemClock"/>;
/// the replay harness uses <c>VirtualClock</c> so 420-second timeouts run in
/// milliseconds and are deterministic.
/// </para>
/// </summary>
public interface IClock
{
/// <summary>Current UTC time.</summary>
DateTime UtcNow { get; }
/// <summary>Async delay; virtualized in tests.</summary>
Task Delay(TimeSpan delay, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,21 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace AutopilotMonitor.DecisionCore.Engine
{
/// <summary>
/// Default <see cref="IClock"/> implementation for the agent runtime — wraps
/// <see cref="DateTime.UtcNow"/> and <see cref="Task.Delay(TimeSpan, CancellationToken)"/>.
/// Tests must use a virtual clock implementation, never this one.
/// </summary>
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);
}
}
@@ -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
{
/// <summary>
/// Centralized <see cref="JsonSerializerSettings"/> factory for DecisionCore DTOs.
/// Plan §2.8 / §2.15 L.14.
/// <para>
/// Produces a <see cref="JsonSerializerSettings"/> instance with:
/// <list type="bullet">
/// <item>All DecisionCore enums registered via <see cref="UnknownFallbackEnumConverter{T}"/>
/// so unknown values land on <c>Unknown</c> instead of throwing.</item>
/// <item><see cref="TypeNameHandling.None"/> — no polymorphism loopholes over the wire.</item>
/// <item><see cref="NullValueHandling.Include"/> — preserve nullable fact/hypothesis fields.</item>
/// <item>UTC round-trip for <c>DateTime</c> — <see cref="DateTimeZoneHandling.Utc"/>,
/// ISO-8601 format.</item>
/// </list>
/// </para>
/// </summary>
public static class DecisionCoreJsonSettings
{
/// <summary>
/// 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.
/// </summary>
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>(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>(EvidenceKind.Synthetic));
settings.Converters.Add(new UnknownFallbackEnumConverter<HypothesisLevel>(HypothesisLevel.Unknown));
settings.Converters.Add(new UnknownFallbackEnumConverter<SessionStage>(SessionStage.Unknown));
settings.Converters.Add(new UnknownFallbackEnumConverter<SessionOutcome>(SessionOutcome.Unknown));
settings.Converters.Add(new UnknownFallbackEnumConverter<DecisionEffectKind>(DecisionEffectKind.PersistSnapshot));
settings.Converters.Add(new UnknownFallbackEnumConverter<EnrollmentPhase>(EnrollmentPhase.Unknown));
return settings;
}
}
}
@@ -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
{
/// <summary>
/// Serialize / deserialize <see cref="DecisionSignal"/> JSONL records.
/// Plan §2.7 / §4 M2 harness integration.
/// <para>
/// Uses Newtonsoft.Json with <see cref="DecisionCoreJsonSettings"/>. The reader
/// tolerates missing optional fields but enforces the evidence-kind mandatory-field
/// contract (plan §2.2) — a line that would fail <c>new Evidence(...)</c> also fails
/// here with the same <see cref="ArgumentException"/>.
/// </para>
/// </summary>
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<JObject>(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<string>("Kind") ?? throw new JsonSerializationException("Evidence.Kind missing.");
var evidenceIdentifier = evidenceTok.Value<string>("Identifier")
?? throw new JsonSerializationException("Evidence.Identifier missing.");
var evidenceSummary = evidenceTok.Value<string>("Summary") ?? string.Empty;
var rawPointer = evidenceTok.Value<string>("RawPointer");
IReadOnlyDictionary<string, string>? derivationInputs = null;
if (evidenceTok["DerivationInputs"] is JObject diObj)
{
var d = new Dictionary<string, string>();
foreach (var prop in diObj.Properties())
{
d[prop.Name] = prop.Value?.ToString() ?? string.Empty;
}
derivationInputs = d;
}
var evidence = new Evidence(
kind: ParseEnum<EvidenceKind>(evidenceKind, EvidenceKind.Synthetic),
identifier: evidenceIdentifier,
summary: evidenceSummary,
rawPointer: rawPointer,
derivationInputs: derivationInputs);
IReadOnlyDictionary<string, string>? payload = null;
if (obj["Payload"] is JObject payloadObj)
{
var p = new Dictionary<string, string>();
foreach (var prop in payloadObj.Properties())
{
p[prop.Name] = prop.Value?.ToString() ?? string.Empty;
}
payload = p;
}
return new DecisionSignal(
sessionSignalOrdinal: obj.Value<long>("SessionSignalOrdinal"),
sessionTraceOrdinal: obj.Value<long>("SessionTraceOrdinal"),
kind: ParseEnum<DecisionSignalKind>(obj.Value<string>("Kind"), DecisionSignalKind.SessionStarted),
kindSchemaVersion: obj.Value<int?>("KindSchemaVersion") ?? 1,
occurredAtUtc: obj.Value<DateTime>("OccurredAtUtc"),
sourceOrigin: obj.Value<string>("SourceOrigin") ?? "unknown",
evidence: evidence,
payload: payload);
}
private static T ParseEnum<T>(string? raw, T fallback) where T : struct, Enum
{
if (string.IsNullOrEmpty(raw)) return fallback;
return Enum.TryParse<T>(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(typeof(T), parsed)
? parsed
: fallback;
}
}
}
@@ -0,0 +1,82 @@
using System;
using Newtonsoft.Json;
namespace AutopilotMonitor.DecisionCore.Serialization
{
/// <summary>
/// Forward-compatibility converter for enums in DecisionCore DTOs. Plan §2.15 L.14.
/// <para>
/// Serializes <typeparamref name="T"/> as its <see cref="Enum.ToString()"/> name.
/// Deserialization maps unknown / unrecognized string values to a caller-provided
/// default — typically <c>Unknown</c> — 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 <c>VerificationStatus=drift_tolerated</c> per plan §2.10.
/// </para>
/// </summary>
public sealed class UnknownFallbackEnumConverter<T> : 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<T>(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(typeof(T), parsed))
{
return parsed;
}
return _unknownFallback;
}
return _unknownFallback;
}
}
}
@@ -131,6 +131,13 @@ namespace AutopilotMonitor.DecisionCore.State
public string SchemaVersion { get; }
/// <summary>
/// Produce a mutable builder pre-populated with this state's values.
/// Reducer handlers call <c>state.ToBuilder().WithStage(...).Build()</c> to
/// express immutable "copy with changes" ergonomically (plan §2.3 / L.3).
/// </summary>
public DecisionStateBuilder ToBuilder() => new DecisionStateBuilder(this);
/// <summary>
/// Construct the initial non-terminal state for a new session.
/// Used by the reducer's <c>SessionStarted</c> handler in M3.
@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Mutable construction helper for producing a new immutable <see cref="DecisionState"/>.
/// <para>
/// Plan §2.3 / L.3 — <c>DecisionState</c> itself is immutable; reducer handlers use the
/// builder to express "copy with changes" without typing all 20+ constructor arguments.
/// Call <see cref="Build"/> to materialize a new <see cref="DecisionState"/>. The original
/// state is never mutated.
/// </para>
/// </summary>
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<ActiveDeadline>(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<EnrollmentPhase>? CurrentEnrollmentPhase { get; set; }
public SignalFact<DateTime>? DeviceSetupEnteredUtc { get; set; }
public SignalFact<DateTime>? AccountSetupEnteredUtc { get; set; }
public SignalFact<DateTime>? FinalizingEnteredUtc { get; set; }
public SignalFact<DateTime>? EspFinalExitUtc { get; set; }
public SignalFact<DateTime>? DesktopArrivedUtc { get; set; }
public SignalFact<DateTime>? HelloResolvedUtc { get; set; }
public SignalFact<DateTime>? SystemRebootUtc { get; set; }
public SignalFact<string>? HelloOutcome { get; set; }
public SignalFact<bool>? AadJoinedWithUser { get; set; }
public SignalFact<string>? ImeMatchedPatternId { get; set; }
public List<ActiveDeadline> 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<EnrollmentPhase>(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);
}
}