diff --git a/.claude/architecture-principles.md b/.claude/architecture-principles.md
new file mode 100644
index 00000000..ab0008af
--- /dev/null
+++ b/.claude/architecture-principles.md
@@ -0,0 +1,145 @@
+# Autopilot-Monitor — Architecture Principles
+
+**Source of truth:** [plans/REFACTOR_AGENT_V2.md](../plans/REFACTOR_AGENT_V2.md) §7.
+This file is a git-versioned extract for persistent Claude-session context. On every
+architecture-relevant plan change, both locations are updated in sync.
+
+The principles outlive this plan — they carry the design substance we do not want to
+re-derive every time. They are **not nice-to-haves**, they are review criteria: any code
+change that violates a principle is either (a) a bug, (b) a reason to change the principle
+(with user sign-off), or (c) an explicitly documented exception with rationale.
+
+---
+
+## L.1 Truth Hierarchy (immutable)
+
+`SignalLog` (input) → `Journal` (decisions) → `Snapshot` (cache) → `Projections` (derivable).
+No projection patches backwards into the journal or the signal log. On inconsistency, the
+lower layer always wins (e.g. discard the snapshot, do not "repair" the signal log).
+
+## L.2 Event Sourcing as Foundation
+
+`SignalLog` is the only input truth. The reducer is pure:
+`(oldState, signal) → (newState, transition, effects)`. The backend can re-derive at any
+time from `SignalLog`. Replay yields the same `Journal` content.
+
+## L.3 Immutable State
+
+All DTOs in the decision core are `sealed` with get-only properties. No in-place mutation.
+"Change" = new instance via `With…` method. This is the difference between "purely testable"
+and "subtle heisenbugs".
+
+## L.4 Hypothesis Model instead of Flow Plugins
+
+Fuzziness is constitutive (White-Glove, enrollment type, device-only). A single reducer with
+hypothesis fields (`Unknown/Weak/Strong/Confirmed/Rejected`), organized by scenario in partial
+classes. **No** plugin runtime, because clean flow boundaries do not match reality.
+
+## L.5 Evidence Determinism
+
+Signal-log determinism is guaranteed: same log → same outcome. Every signal carries an
+`Evidence` record with `Kind` (Raw / Derived / Synthetic) and mandatory fields. A signal
+without evidence = adapter rejection = bug.
+
+## L.6 Signal Schema Versioning
+
+Every `DecisionSignalKind` has a `SchemaVersion`. The reducer dispatches on
+`(Kind, SchemaVersion)` — old versions stay replay-capable. A new version or a new kind
+without a replay fixture in `tests/fixtures/signal-kinds/{kind}-v{n}.json` = merge block.
+
+## L.7 Timer Monopoly
+
+Only the Deadline Scheduler (the EffectRunner role) holds decision-relevant timers. Collectors,
+signal adapters and the orchestrator must not. `Task.Delay` or `new Timer(...)` in those
+layers with session-state impact = merge block. `IClock` interface + virtual clock for
+testability.
+
+## L.8 Classifier as Post-Hook Kernel Service
+
+`IClassifier` interface, swappable. The `ClassifierVerdict` flows back into the log as a
+synthetic `ClassifierVerdictIssued` signal. `InputHash` (SHA256 over classifier-relevant
+snapshot fields) is the anti-loop guard. Classifiers are not baked into the state machine.
+
+## L.9 Clean-Agent Principle on Refactors
+
+For structural refactors: build the new project in parallel to the legacy one, do not flag-
+route inside the same process. Strict separation rule: no direct reference to legacy
+projects. Code is copied, not shared. Legacy runs out, then gets removed.
+
+## L.10 Generic Telemetry Transport
+
+One spool for all telemetry types (events, signals, decision transitions). `TelemetryItemId`
+is the type-agnostic upload cursor; the type-specific counters (`Event.Sequence`,
+`SignalOrdinal`, `StepIndex`) remain storage / UI / reducer semantics. Drain guarantee:
+"coordinated + idempotent + resumable" — **not** cross-table atomic, which is not achievable
+with three separate tables anyway.
+
+## L.11 Separation: Agent Lifecycle vs Session State
+
+`DecisionState` is session-pure. Agent-process flags (crash, admin actions, boot time,
+heartbeat status) live separately in `agent-lifecycle.json`, managed by the orchestrator,
+never by the reducer. Prevents replay dilution by lifecycle events.
+
+## L.12 Flush-on-Append Persistence
+
+No batching of append operations. `FileOptions.WriteThrough` plus an explicit
+`FlushAsync(flushToDisk: true)` per append. Every successful `Append` return means: on disk.
+Durability > performance in the crash-prone OOBE context.
+
+## L.13 UI Primacy — End-Users vs Dev Tooling
+
+Product views (EventTimeline, PhaseTimeline) stay slim and stable. Debug / analysis tools
+(Inspector with DecisionGraph, SignalStream, VerifierReport) live on their own subpage behind
+an `adminMode` gate. The session-detail page does not get overloaded with dev components.
+
+## L.14 Forward Compatibility by Default
+
+Enums via `StringEnumConverter` + `UnknownFallbackEnumConverter` (unknown values → default,
+never crash). New schema fields are nullable. The `ReducerVersion` stamp per journal row
+allows older backends to read newer rows. Deletions only after a major version bump with
+user sign-off.
+
+## L.15 Test Discipline
+
+Flow / scenario tests are prioritized over structural tests. Coverage targets per component
+(DecisionCore ≥95 %, classifier 100 %, backend functions ≥85 %). A missing fixture for a
+scenario = merge block. CI threshold check is a build failure, not a warning.
+
+## L.16 Fail-Safe Reducer Exceptions
+
+Exception in the reducer → `DecisionTransition` with `DeadEndReason=reducer_exception`, state
+unchanged (fail-safe, never destroy state). Signal counts as processed (no retry loop). After
+five consecutive exceptions → the orchestrator emits
+`enrollment_failed(reason=reducer_instability)`.
+
+## L.17 Effect Error Handling by Class
+
+Three effect classes with their own strategy:
+
+- **Transient** (retry with exponential backoff 100 ms / 400 ms / 1600 ms):
+ `EmitEventTimelineEntry`, `PersistSnapshot`
+- **Critical** (session abort, e.g. on timer-register failure): `ScheduleDeadline`,
+ `CancelDeadline`
+- **Optional** (best-effort, classifier exception → `Inconclusive` verdict, no abort):
+ `RunClassifier`
+
+Every effect failure is logged as a `DecisionTransition`.
+
+## L.18 Implementation Discipline
+
+- **R1** Pre-flight plan check before every work step
+- **R2** Delegate self-contained sub-tasks **and exploration** to subagents (Anthropic
+ recommendation: preserve main-context window on large refactors — quality assurance)
+- **R3** Real verification (DB round-trip, index dual-write, agent signal emission) — no
+ "it compiles and runs"
+- **R4** The plan is authority — deviations require user sign-off
+- **R5** Out-of-scope items land in `tasks/todo.md`, not in memory
+- **R6** High test coverage is a prerequisite for future refactorability, not a nice-to-have
+- **R7** Code-structure discipline: small files (guideline ≤300 LoC), clear folders, partial
+ classes by scenario, check in with the user on any uncertainty (placement, naming, scope)
+
+---
+
+**Maintaining these principles:** On every architecture-relevant plan change, the
+corresponding L.n points are updated here **and** in [plans/REFACTOR_AGENT_V2.md](../plans/REFACTOR_AGENT_V2.md)
+§7 in sync. New principles are discussed in the plan first, then migrated here.
diff --git a/AutopilotMonitor.sln b/AutopilotMonitor.sln
index 1ddc606d..12db1d33 100644
--- a/AutopilotMonitor.sln
+++ b/AutopilotMonitor.sln
@@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.SummaryDia
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.Core.Tests", "src\Agent\AutopilotMonitor.Agent.Core.Tests\AutopilotMonitor.Agent.Core.Tests.csproj", "{89BDAC07-A666-4708-A9D3-A956A89A4E4D}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.DecisionCore", "src\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj", "{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -119,6 +121,18 @@ Global
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x64.Build.0 = Release|Any CPU
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x86.ActiveCfg = Release|Any CPU
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x86.Build.0 = Release|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|x64.Build.0 = Debug|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|x86.Build.0 = Debug|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x64.ActiveCfg = Release|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x64.Build.0 = Release|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x86.ActiveCfg = Release|Any CPU
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -129,10 +143,11 @@ Global
{096B6098-F141-7114-B090-72D1899D8853} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B} = {096B6098-F141-7114-B090-72D1899D8853}
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC} = {096B6098-F141-7114-B090-72D1899D8853}
- {B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E} = {096B6098-F141-7114-B090-72D1899D8853}
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D} = {096B6098-F141-7114-B090-72D1899D8853}
{FE360695-3F2B-1049-3887-35FBB5135923} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682} = {FE360695-3F2B-1049-3887-35FBB5135923}
{E3E0A5D8-4899-4F41-9D5F-CB858EF9F091} = {FE360695-3F2B-1049-3887-35FBB5135923}
+ {B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E} = {096B6098-F141-7114-B090-72D1899D8853}
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D} = {096B6098-F141-7114-B090-72D1899D8853}
+ {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
EndGlobalSection
EndGlobal
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj b/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj
index 7604f68c..d42fcf33 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj
+++ b/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj
@@ -16,6 +16,7 @@
+
diff --git a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
index 407cd929..4acadfad 100644
--- a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
+++ b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
@@ -30,6 +30,7 @@
+
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj b/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj
new file mode 100644
index 00000000..bd34b6db
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj
@@ -0,0 +1,21 @@
+
+
+
+ netstandard2.0
+ 9.0
+ enable
+ true
+ 2.0.0.0
+ 2.0.0.0
+
+ Decision Core shared reducer library for Autopilot-Monitor Agent v2.0.
+ Consumed by Agent (net48) and Backend (net8.0). Pure, dependency-minimal.
+ AssemblyVersion is the ReducerVersion source (plan §2.10).
+
+
+
+
+
+
+
+
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/ClassifierVerdict.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/ClassifierVerdict.cs
new file mode 100644
index 00000000..27bfb7fc
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/ClassifierVerdict.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.DecisionCore.Classifiers
+{
+ ///
+ /// Immutable verdict record produced by an . Plan §2.4.
+ ///
+ /// Emitted back into the signal log as a synthetic
+ /// ClassifierVerdictIssued signal (L.8) and attached to the corresponding
+ /// DecisionTransition.ClassifierVerdictJson.
+ ///
+ ///
+ public sealed class ClassifierVerdict
+ {
+ public ClassifierVerdict(
+ string classifierId,
+ HypothesisLevel level,
+ int score,
+ IReadOnlyList contributingFactors,
+ string reason,
+ string inputHash)
+ {
+ if (string.IsNullOrEmpty(classifierId))
+ {
+ throw new ArgumentException("ClassifierId is mandatory.", nameof(classifierId));
+ }
+
+ if (string.IsNullOrEmpty(inputHash))
+ {
+ throw new ArgumentException("InputHash is mandatory.", nameof(inputHash));
+ }
+
+ if (score < 0 || score > 100)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(score),
+ "Score must be in [0, 100].");
+ }
+
+ ClassifierId = classifierId;
+ Level = level;
+ Score = score;
+ ContributingFactors = contributingFactors ?? Array.Empty();
+ Reason = reason ?? string.Empty;
+ InputHash = inputHash;
+ }
+
+ public string ClassifierId { get; }
+
+ public HypothesisLevel Level { get; }
+
+ public int Score { get; }
+
+ public IReadOnlyList ContributingFactors { get; }
+
+ public string Reason { get; }
+
+ /// SHA256-based hex hash (16 chars) over classifier-specific snapshot. Plan §2.4 — anti-loop.
+ public string InputHash { get; }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifier.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifier.cs
new file mode 100644
index 00000000..437064c8
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifier.cs
@@ -0,0 +1,29 @@
+using System;
+
+namespace AutopilotMonitor.DecisionCore.Classifiers
+{
+ ///
+ /// Kernel classifier service. Plan §2.4 / L.8.
+ ///
+ /// Implementations are pure: Classify(snapshot) → ClassifierVerdict.
+ /// Anti-loop via — the reducer does not re-invoke
+ /// a classifier whose last verdict matches the current snapshot hash.
+ ///
+ ///
+ /// First implementations shipped in M3: WhiteGloveSealingClassifier,
+ /// WhiteGlovePart2CompletionClassifier. Future (v11.1+): EnrollmentFailureCauseClassifier,
+ /// AppInstallOutcomeClassifier.
+ ///
+ ///
+ public interface IClassifier
+ {
+ /// Stable identifier used in verdict records and journal references.
+ string Id { get; }
+
+ /// Concrete snapshot type the classifier expects. Enforced by the reducer.
+ Type SnapshotType { get; }
+
+ /// Run the classifier against a snapshot.
+ ClassifierVerdict Classify(object snapshot);
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEffect.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEffect.cs
new file mode 100644
index 00000000..f4f6630f
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEffect.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ ///
+ /// Action kind an requests the EffectRunner to execute. Plan §2.5 / §2.7b.
+ ///
+ public enum DecisionEffectKind
+ {
+ ScheduleDeadline,
+ CancelDeadline,
+ RunClassifier,
+ EmitEventTimelineEntry,
+ PersistSnapshot,
+ }
+
+ ///
+ /// Immutable effect record. Plan §2.5.
+ ///
+ /// Effects are the only side-channel through which the reducer talks to the outside world.
+ /// The reducer itself is pure ((old, signal) → (new, transition, effects)); the EffectRunner
+ /// performs I/O (Deadlines, Classifier, Event-Emit, Persist) per plan §2.7b error strategy:
+ ///
+ ///
+ /// - Transient (retry, 100/400/1600 ms): ,
+ /// - Critical (session abort on register failure): ,
+ /// - Optional (best-effort, no abort):
+ ///
+ ///
+ public sealed class DecisionEffect
+ {
+ public DecisionEffect(
+ DecisionEffectKind kind,
+ IReadOnlyDictionary? parameters = null,
+ ActiveDeadline? deadline = null,
+ string? cancelDeadlineName = null,
+ string? classifierId = null,
+ object? classifierSnapshot = null)
+ {
+ Kind = kind;
+ Parameters = parameters;
+ Deadline = deadline;
+ CancelDeadlineName = cancelDeadlineName;
+ ClassifierId = classifierId;
+ ClassifierSnapshot = classifierSnapshot;
+ }
+
+ public DecisionEffectKind Kind { get; }
+
+ public IReadOnlyDictionary? Parameters { get; }
+
+ /// Set for .
+ public ActiveDeadline? Deadline { get; }
+
+ /// Set for .
+ public string? CancelDeadlineName { get; }
+
+ /// Set for .
+ public string? ClassifierId { get; }
+
+ /// Set for — classifier-specific snapshot object.
+ public object? ClassifierSnapshot { get; }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
new file mode 100644
index 00000000..d88f68ee
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Reflection;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ ///
+ /// Kernel stub for the Decision Engine. Plan §2.5 + M1 gate.
+ ///
+ /// 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").
+ ///
+ ///
+ /// 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).
+ ///
+ ///
+ public sealed partial class DecisionEngine : IDecisionEngine
+ {
+ private static readonly string s_reducerVersion =
+ typeof(DecisionEngine).GetTypeInfo().Assembly.GetName().Version?.ToString() ?? "0.0.0.0";
+
+ public string ReducerVersion => s_reducerVersion;
+
+ public DecisionStep Reduce(DecisionState oldState, DecisionSignal signal)
+ {
+ if (oldState == null)
+ {
+ throw new ArgumentNullException(nameof(oldState));
+ }
+
+ if (signal == null)
+ {
+ throw new ArgumentNullException(nameof(signal));
+ }
+
+ throw new NotImplementedException(
+ "DecisionEngine.Reduce is a scaffolding stub (M1). " +
+ "Handler implementation lands in M3 (plan §2.5).");
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStep.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStep.cs
new file mode 100644
index 00000000..a2e3749c
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStep.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ ///
+ /// Return value of a single reducer call. Plan §2.3 contract:
+ /// (newState, transition, effects) = engine.Reduce(oldState, signal).
+ ///
+ public sealed class DecisionStep
+ {
+ public DecisionStep(
+ DecisionState newState,
+ DecisionTransition transition,
+ IReadOnlyList effects)
+ {
+ NewState = newState ?? throw new ArgumentNullException(nameof(newState));
+ Transition = transition ?? throw new ArgumentNullException(nameof(transition));
+ Effects = effects ?? throw new ArgumentNullException(nameof(effects));
+ }
+
+ public DecisionState NewState { get; }
+
+ public DecisionTransition Transition { get; }
+
+ public IReadOnlyList Effects { get; }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionTransition.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionTransition.cs
new file mode 100644
index 00000000..d4ec8d5f
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionTransition.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ ///
+ /// Immutable journal record for a single reducer step. Plan §2.8.
+ ///
+ /// Taken=true: a state-changing transition. Taken=false: a dead-end
+ /// (trigger arrived, at least one guard failed — recorded so the Inspector can show the
+ /// blocked path in the decision graph, §2.9).
+ ///
+ ///
+ /// Cross-type correlation: links to the triggering signal,
+ /// lists Event.Sequence values of any timeline events
+ /// this step emitted, and is the session-wide monotonic
+ /// counter shared across Event / Signal / Transition (plan §2.2 correlation IDs).
+ ///
+ ///
+ public sealed class DecisionTransition
+ {
+ public DecisionTransition(
+ int stepIndex,
+ long sessionTraceOrdinal,
+ long signalOrdinalRef,
+ DateTime occurredAtUtc,
+ string trigger,
+ SessionStage fromStage,
+ SessionStage toStage,
+ bool taken,
+ string? deadEndReason,
+ string reducerVersion,
+ IReadOnlyList? guards = null,
+ IReadOnlyList? emittedEventSequences = null,
+ ClassifierVerdict? classifierVerdict = null,
+ string? errorMessage = null,
+ string? stackTraceHash = null)
+ {
+ if (stepIndex < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(stepIndex),
+ "StepIndex must be non-negative.");
+ }
+
+ if (string.IsNullOrEmpty(trigger))
+ {
+ throw new ArgumentException("Trigger is mandatory.", nameof(trigger));
+ }
+
+ if (string.IsNullOrEmpty(reducerVersion))
+ {
+ throw new ArgumentException("ReducerVersion is mandatory.", nameof(reducerVersion));
+ }
+
+ StepIndex = stepIndex;
+ SessionTraceOrdinal = sessionTraceOrdinal;
+ SignalOrdinalRef = signalOrdinalRef;
+ OccurredAtUtc = occurredAtUtc;
+ Trigger = trigger;
+ FromStage = fromStage;
+ ToStage = toStage;
+ Taken = taken;
+ DeadEndReason = deadEndReason;
+ ReducerVersion = reducerVersion;
+ Guards = guards ?? Array.Empty();
+ EmittedEventSequences = emittedEventSequences ?? Array.Empty();
+ ClassifierVerdict = classifierVerdict;
+ ErrorMessage = errorMessage;
+ StackTraceHash = stackTraceHash;
+ }
+
+ public int StepIndex { get; }
+
+ public long SessionTraceOrdinal { get; }
+
+ /// Plan §2.2 cross-reference — which signal caused this transition.
+ public long SignalOrdinalRef { get; }
+
+ public DateTime OccurredAtUtc { get; }
+
+ public string Trigger { get; }
+
+ public SessionStage FromStage { get; }
+
+ public SessionStage ToStage { get; }
+
+ public bool Taken { get; }
+
+ ///
+ /// Non-null when is false. Plan §2.5 also uses well-known reasons:
+ /// "reducer_exception", "effect_failure", "hybrid_reboot_gate_blocking", …
+ ///
+ public string? DeadEndReason { get; }
+
+ public string ReducerVersion { get; }
+
+ public IReadOnlyList Guards { get; }
+
+ /// Plan §2.2 cross-reference — Event.Sequence values this transition emitted as effects.
+ public IReadOnlyList EmittedEventSequences { get; }
+
+ public ClassifierVerdict? ClassifierVerdict { get; }
+
+ /// Set when == "reducer_exception" (plan §2.5 fail-safe).
+ public string? ErrorMessage { get; }
+
+ /// SHA256-short hash of the original stack trace. Prevents unbounded PII in the journal.
+ public string? StackTraceHash { get; }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/GuardReport.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/GuardReport.cs
new file mode 100644
index 00000000..675113d5
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/GuardReport.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ ///
+ /// Guard-evaluation record emitted during a reducer step. Plan §2.5 / §2.8 (Journal
+ /// GuardsJson). A taken transition may have zero or more guard evaluations;
+ /// a dead-end transition has at least one Passed=false guard that explains
+ /// the rejection.
+ ///
+ public sealed class GuardReport
+ {
+ public GuardReport(string guardId, bool passed, string? reason = null)
+ {
+ if (string.IsNullOrEmpty(guardId))
+ {
+ throw new ArgumentException("GuardId is mandatory.", nameof(guardId));
+ }
+
+ GuardId = guardId;
+ Passed = passed;
+ Reason = reason;
+ }
+
+ public string GuardId { get; }
+
+ public bool Passed { get; }
+
+ public string? Reason { get; }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/IDecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/IDecisionEngine.cs
new file mode 100644
index 00000000..fab6ab1f
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/IDecisionEngine.cs
@@ -0,0 +1,26 @@
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ ///
+ /// Pure reducer interface. Plan §2.5 / L.2 / L.3.
+ ///
+ /// Contract: must be treated as immutable and is never
+ /// modified. The returned is a fresh instance.
+ /// Signal-log determinism (L.5): the same persisted signal stream always produces the
+ /// same journal and the same terminal outcome.
+ ///
+ ///
+ public interface IDecisionEngine
+ {
+ /// ReducerVersion stamp written into each journal transition. Drift-detection in §2.10.
+ string ReducerVersion { get; }
+
+ ///
+ /// Apply a single signal to the state and return (newState, transition, effects).
+ /// Stubbed in M1; full handler dispatch (partial classes §2.5) comes online in M3.
+ ///
+ DecisionStep Reduce(DecisionState oldState, DecisionSignal signal);
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignal.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignal.cs
new file mode 100644
index 00000000..78301c0a
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignal.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+
+namespace AutopilotMonitor.DecisionCore.Signals
+{
+ ///
+ /// Immutable input unit to the Decision Engine. Plan §2.2.
+ ///
+ /// The SignalLog is append-only and is the single source of input truth (plan §2.7c L.1).
+ /// Ordinals are assigned exclusively by SignalIngress (single-writer serialization, §2.1a)
+ /// — never by collectors or adapters directly.
+ ///
+ ///
+ public sealed class DecisionSignal
+ {
+ public DecisionSignal(
+ long sessionSignalOrdinal,
+ long sessionTraceOrdinal,
+ DecisionSignalKind kind,
+ int kindSchemaVersion,
+ DateTime occurredAtUtc,
+ string sourceOrigin,
+ Evidence evidence,
+ IReadOnlyDictionary? payload = null)
+ {
+ if (kindSchemaVersion < 1)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(kindSchemaVersion),
+ "SchemaVersion must be >= 1.");
+ }
+
+ if (string.IsNullOrEmpty(sourceOrigin))
+ {
+ throw new ArgumentException(
+ "SourceOrigin is mandatory.",
+ nameof(sourceOrigin));
+ }
+
+ SessionSignalOrdinal = sessionSignalOrdinal;
+ SessionTraceOrdinal = sessionTraceOrdinal;
+ Kind = kind;
+ KindSchemaVersion = kindSchemaVersion;
+ OccurredAtUtc = occurredAtUtc;
+ SourceOrigin = sourceOrigin;
+ Evidence = evidence ?? throw new ArgumentNullException(nameof(evidence));
+ Payload = payload;
+ }
+
+ /// Monotonic per SignalLog; RowKey for Azure Signals table.
+ public long SessionSignalOrdinal { get; }
+
+ /// Session-wide monotonic across all telemetry types (Event + Signal + Transition). Inspector correlation only.
+ public long SessionTraceOrdinal { get; }
+
+ public DecisionSignalKind Kind { get; }
+
+ /// Plan §2.2 L.6 — reducer dispatches on (Kind, KindSchemaVersion).
+ public int KindSchemaVersion { get; }
+
+ public DateTime OccurredAtUtc { get; }
+
+ public string SourceOrigin { get; }
+
+ public Evidence Evidence { get; }
+
+ public IReadOnlyDictionary? Payload { get; }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
new file mode 100644
index 00000000..a704411f
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
@@ -0,0 +1,45 @@
+namespace AutopilotMonitor.DecisionCore.Signals
+{
+ ///
+ /// All signal kinds consumed by the Decision Engine.
+ /// Plan §2.2. Every kind is versioned via ;
+ /// reducer handlers dispatch on (Kind, SchemaVersion). New kinds or version bumps
+ /// require a replay fixture in tests/fixtures/signal-kinds/{kind}-v{n}.json
+ /// — missing fixture = merge block.
+ ///
+ public enum DecisionSignalKind
+ {
+ // --- Raw — Part 1 ---
+ EspPhaseChanged,
+ EspExiting,
+ EspResumed,
+ EspTerminalFailure,
+ DesktopArrived,
+ HelloResolved,
+ ImeUserSessionCompleted,
+ DeviceSetupProvisioningComplete,
+ AppInstallCompleted,
+ AppInstallFailed,
+ WhiteGloveShellCoreSuccess,
+ WhiteGloveSealingPatternDetected,
+ AadUserJoinedLate,
+ SystemRebootObserved,
+ DeviceInfoCollected,
+ AutopilotProfileRead,
+
+ // --- Raw — WhiteGlove Part 2 (Post-Reboot User-Sign-In) ---
+ UserAadSignInComplete,
+ HelloResolvedPart2,
+ DesktopArrivedPart2,
+ AccountSetupCompletedPart2,
+
+ // --- Synthetic ---
+ DeadlineFired,
+ ClassifierVerdictIssued,
+
+ // --- Lifecycle ---
+ SessionStarted,
+ SessionAborted,
+ SessionRecovered,
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/Evidence.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/Evidence.cs
new file mode 100644
index 00000000..ec89a244
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/Evidence.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+
+namespace AutopilotMonitor.DecisionCore.Signals
+{
+ ///
+ /// Immutable evidence record attached to every .
+ /// Plan §2.2 — pflichtfeld-validation in the signal adapter; signal without
+ /// evidence = bug. Per-kind mandatory fields:
+ ///
+ /// - Raw: , ,
+ /// - Derived: all Raw fields + non-empty
+ /// - Synthetic: , ,
+ ///
+ ///
+ public sealed class Evidence
+ {
+ public Evidence(
+ EvidenceKind kind,
+ string identifier,
+ string summary,
+ string? rawPointer = null,
+ IReadOnlyDictionary? derivationInputs = null)
+ {
+ if (string.IsNullOrEmpty(identifier))
+ {
+ throw new ArgumentException("Evidence.Identifier is mandatory.", nameof(identifier));
+ }
+
+ if (summary == null)
+ {
+ throw new ArgumentNullException(nameof(summary));
+ }
+
+ if (kind == EvidenceKind.Derived && (derivationInputs == null || derivationInputs.Count == 0))
+ {
+ throw new ArgumentException(
+ "Evidence.Derived requires at least one DerivationInputs entry.",
+ nameof(derivationInputs));
+ }
+
+ Kind = kind;
+ Identifier = identifier;
+ Summary = summary.Length > 256 ? summary.Substring(0, 256) : summary;
+ RawPointer = rawPointer;
+ DerivationInputs = derivationInputs;
+ }
+
+ public EvidenceKind Kind { get; }
+
+ ///
+ /// Raw: RecordId / RegistryPath / LogLineHash.
+ /// Derived: detectorId + version (e.g. "desktop-arrival-detector-v1").
+ /// Synthetic: correlationId (e.g. "deadline:hello_safety:fired").
+ ///
+ public string Identifier { get; }
+
+ /// UI-friendly summary, ≤256 chars (truncated on construction).
+ public string Summary { get; }
+
+ /// Optional blob key for full raw payload retrieval.
+ public string? RawPointer { get; }
+
+ /// Derived-only: which raw observations fed the detector.
+ public IReadOnlyDictionary? DerivationInputs { get; }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/EvidenceKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/EvidenceKind.cs
new file mode 100644
index 00000000..d250840e
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/EvidenceKind.cs
@@ -0,0 +1,17 @@
+namespace AutopilotMonitor.DecisionCore.Signals
+{
+ ///
+ /// Classification of a signal's evidence origin. Plan §2.2.
+ ///
+ public enum EvidenceKind
+ {
+ /// Direct observation from a raw source (event log, registry, IME line).
+ Raw,
+
+ /// Produced by a detector from multiple raw inputs. Requires DerivationInputs.
+ Derived,
+
+ /// Engine-internal synthesis (deadline fired, classifier verdict, session recovered).
+ Synthetic,
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/ActiveDeadline.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/ActiveDeadline.cs
new file mode 100644
index 00000000..a4ee6b5e
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/ActiveDeadline.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Signals;
+
+namespace AutopilotMonitor.DecisionCore.State
+{
+ ///
+ /// Immutable persisted deadline record. Plan §2.6.
+ /// Timer-Monopol (L.7): only the Deadline-Scheduler (EffectRunner-role) holds
+ /// decision-relevant timers. On restart, deadlines are re-hydrated from the
+ /// persisted ; remaining = DueAtUtc - clock.UtcNow.
+ ///
+ public sealed class ActiveDeadline
+ {
+ public ActiveDeadline(
+ string name,
+ DateTime dueAtUtc,
+ DecisionSignalKind firesSignalKind,
+ IReadOnlyDictionary? firesPayload = null)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ throw new ArgumentException("Deadline Name is mandatory.", nameof(name));
+ }
+
+ Name = name;
+ DueAtUtc = dueAtUtc;
+ FiresSignalKind = firesSignalKind;
+ FiresPayload = firesPayload;
+ }
+
+ /// Unique deadline name within a session (e.g. "hello_safety", "part2_safety").
+ public string Name { get; }
+
+ public DateTime DueAtUtc { get; }
+
+ public DecisionSignalKind FiresSignalKind { get; }
+
+ public IReadOnlyDictionary? FiresPayload { get; }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
new file mode 100644
index 00000000..d1a85cd2
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
@@ -0,0 +1,163 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.DecisionCore.State
+{
+ ///
+ /// Immutable snapshot of the engine-visible session state. Plan §2.3.
+ ///
+ /// Invariant: all DTOs in DecisionCore are immutable value objects.
+ /// "Change" is a new instance via With…-methods — no in-place mutation.
+ /// Reducer contract: (newState, transition, effects) = engine.Reduce(oldState, signal).
+ ///
+ ///
+ /// Agent-process lifecycle flags (crash, admin actions, boot-time, heartbeat) live
+ /// in agent-lifecycle.json, not here (L.11 separation).
+ ///
+ ///
+ public sealed class DecisionState
+ {
+ public const string CurrentSchemaVersion = "v2";
+
+ public DecisionState(
+ string sessionId,
+ string tenantId,
+ SessionStage stage,
+ SessionOutcome? outcome,
+ Hypothesis enrollmentType,
+ Hypothesis whiteGloveSealing,
+ Hypothesis whiteGlovePart2Completion,
+ Hypothesis deviceOnlyDeployment,
+ SignalFact? currentEnrollmentPhase,
+ SignalFact? deviceSetupEnteredUtc,
+ SignalFact? accountSetupEnteredUtc,
+ SignalFact? finalizingEnteredUtc,
+ SignalFact? espFinalExitUtc,
+ SignalFact? desktopArrivedUtc,
+ SignalFact? helloResolvedUtc,
+ SignalFact? systemRebootUtc,
+ SignalFact? helloOutcome,
+ SignalFact? aadJoinedWithUser,
+ SignalFact? imeMatchedPatternId,
+ IReadOnlyList deadlines,
+ long lastAppliedSignalOrdinal,
+ int stepIndex,
+ string? schemaVersion = null)
+ {
+ if (string.IsNullOrEmpty(sessionId))
+ {
+ throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
+ }
+
+ if (string.IsNullOrEmpty(tenantId))
+ {
+ throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
+ }
+
+ SessionId = sessionId;
+ TenantId = tenantId;
+ Stage = stage;
+ Outcome = outcome;
+ EnrollmentType = enrollmentType ?? throw new ArgumentNullException(nameof(enrollmentType));
+ WhiteGloveSealing = whiteGloveSealing ?? throw new ArgumentNullException(nameof(whiteGloveSealing));
+ WhiteGlovePart2Completion = whiteGlovePart2Completion ?? throw new ArgumentNullException(nameof(whiteGlovePart2Completion));
+ DeviceOnlyDeployment = deviceOnlyDeployment ?? throw new ArgumentNullException(nameof(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 ?? throw new ArgumentNullException(nameof(deadlines));
+ LastAppliedSignalOrdinal = lastAppliedSignalOrdinal;
+ StepIndex = stepIndex;
+ SchemaVersion = schemaVersion ?? CurrentSchemaVersion;
+ }
+
+ public string SessionId { get; }
+
+ public string TenantId { get; }
+
+ /// Engine-stage — what the reducer is currently waiting on.
+ public SessionStage Stage { get; }
+
+ /// Null when the session is non-terminal.
+ public SessionOutcome? Outcome { get; }
+
+ // --- Hypothesen ---
+ public Hypothesis EnrollmentType { get; }
+
+ public Hypothesis WhiteGloveSealing { get; }
+
+ public Hypothesis WhiteGlovePart2Completion { get; }
+
+ public Hypothesis DeviceOnlyDeployment { get; }
+
+ // --- Enrollment-Phase (end-user reality, separate from Stage) ---
+ public SignalFact? CurrentEnrollmentPhase { get; }
+
+ public SignalFact? DeviceSetupEnteredUtc { get; }
+
+ public SignalFact? AccountSetupEnteredUtc { get; }
+
+ public SignalFact? FinalizingEnteredUtc { get; }
+
+ // --- Signal-induced facts (with source ordinal for evidence trace) ---
+ public SignalFact? EspFinalExitUtc { get; }
+
+ public SignalFact? DesktopArrivedUtc { get; }
+
+ public SignalFact? HelloResolvedUtc { get; }
+
+ public SignalFact? SystemRebootUtc { get; }
+
+ public SignalFact? HelloOutcome { get; }
+
+ public SignalFact? AadJoinedWithUser { get; }
+
+ public SignalFact? ImeMatchedPatternId { get; }
+
+ public IReadOnlyList Deadlines { get; }
+
+ public long LastAppliedSignalOrdinal { get; }
+
+ public int StepIndex { get; }
+
+ public string SchemaVersion { get; }
+
+ ///
+ /// Construct the initial non-terminal state for a new session.
+ /// Used by the reducer's SessionStarted handler in M3.
+ ///
+ public static DecisionState CreateInitial(string sessionId, string tenantId) =>
+ new DecisionState(
+ sessionId: sessionId,
+ tenantId: tenantId,
+ stage: SessionStage.SessionStarted,
+ outcome: null,
+ enrollmentType: Hypothesis.UnknownInstance,
+ whiteGloveSealing: Hypothesis.UnknownInstance,
+ whiteGlovePart2Completion: Hypothesis.UnknownInstance,
+ deviceOnlyDeployment: Hypothesis.UnknownInstance,
+ currentEnrollmentPhase: null,
+ deviceSetupEnteredUtc: null,
+ accountSetupEnteredUtc: null,
+ finalizingEnteredUtc: null,
+ espFinalExitUtc: null,
+ desktopArrivedUtc: null,
+ helloResolvedUtc: null,
+ systemRebootUtc: null,
+ helloOutcome: null,
+ aadJoinedWithUser: null,
+ imeMatchedPatternId: null,
+ deadlines: Array.Empty(),
+ lastAppliedSignalOrdinal: -1,
+ stepIndex: 0);
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/Hypothesis.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/Hypothesis.cs
new file mode 100644
index 00000000..883ed58e
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/Hypothesis.cs
@@ -0,0 +1,65 @@
+using System;
+
+namespace AutopilotMonitor.DecisionCore.State
+{
+ ///
+ /// Immutable engine hypothesis record. Plan §2.3.
+ /// Replaces boolean flag state in trackers with an explicit
+ /// level/score/reason/evidence-trace tuple.
+ ///
+ public sealed class Hypothesis
+ {
+ public static readonly Hypothesis UnknownInstance = new Hypothesis(
+ HypothesisLevel.Unknown,
+ reason: null,
+ score: 0,
+ lastUpdatedUtc: DateTime.MinValue,
+ lastClassifierVerdictId: null);
+
+ public Hypothesis(
+ HypothesisLevel level,
+ string? reason,
+ int score,
+ DateTime lastUpdatedUtc,
+ string? lastClassifierVerdictId)
+ {
+ if (score < 0 || score > 100)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(score),
+ "Score must be in [0, 100].");
+ }
+
+ Level = level;
+ Reason = reason;
+ Score = score;
+ LastUpdatedUtc = lastUpdatedUtc;
+ LastClassifierVerdictId = lastClassifierVerdictId;
+ }
+
+ public HypothesisLevel Level { get; }
+
+ public string? Reason { get; }
+
+ /// 0..100 — classifier score or reducer-assigned confidence.
+ public int Score { get; }
+
+ public DateTime LastUpdatedUtc { get; }
+
+ /// Journal reference to the classifier verdict that last touched this hypothesis, if any.
+ public string? LastClassifierVerdictId { get; }
+
+ public Hypothesis With(
+ HypothesisLevel? level = null,
+ string? reason = null,
+ int? score = null,
+ DateTime? lastUpdatedUtc = null,
+ string? lastClassifierVerdictId = null) =>
+ new Hypothesis(
+ level ?? Level,
+ reason ?? Reason,
+ score ?? Score,
+ lastUpdatedUtc ?? LastUpdatedUtc,
+ lastClassifierVerdictId ?? LastClassifierVerdictId);
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/HypothesisLevel.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/HypothesisLevel.cs
new file mode 100644
index 00000000..63595c08
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/HypothesisLevel.cs
@@ -0,0 +1,20 @@
+namespace AutopilotMonitor.DecisionCore.State
+{
+ ///
+ /// Confidence levels for an engine hypothesis. Plan §2.3.
+ ///
+ public enum HypothesisLevel
+ {
+ Unknown = 0,
+ Weak = 1,
+ Strong = 2,
+ Confirmed = 3,
+ Rejected = 4,
+
+ ///
+ /// Plan §2.7b — Classifier exception / best-effort failure.
+ /// Hypothesis keeps its prior level; verdict recorded as Inconclusive, no abort.
+ ///
+ Inconclusive = 5,
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs
new file mode 100644
index 00000000..11694d36
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs
@@ -0,0 +1,17 @@
+namespace AutopilotMonitor.DecisionCore.State
+{
+ ///
+ /// Terminal outcome of a session. Plan §2.3.
+ /// Non-null only when is a terminal stage
+ /// (Completed, Failed, or WhiteGloveCompletedPart2).
+ ///
+ public enum SessionOutcome
+ {
+ Unknown = 0,
+ EnrollmentComplete,
+ EnrollmentFailed,
+ WhiteGlovePart1Sealed,
+ WhiteGlovePart2Complete,
+ Aborted,
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStage.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStage.cs
new file mode 100644
index 00000000..660d7913
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStage.cs
@@ -0,0 +1,41 @@
+namespace AutopilotMonitor.DecisionCore.State
+{
+ ///
+ /// Engine stage — what the engine is currently waiting on. Plan §2.3.
+ /// Distinct from EnrollmentPhase (end-user reality) and from Hypothesis (engine guesses).
+ ///
+ /// Placeholder stages will be extended as partial-class reducers (§2.5) come online in M3.
+ /// This enum is extended additively; new values are forward-compatible via
+ /// UnknownFallbackEnumConverter (§2.15 L.14).
+ ///
+ ///
+ public enum SessionStage
+ {
+ Unknown = 0,
+ SessionStarted,
+
+ // Classic (UserDriven-v1)
+ AwaitingEspPhaseChange,
+ EspDeviceSetup,
+ EspAccountSetup,
+ AwaitingHello,
+ AwaitingDesktop,
+ DesktopArrivedEspBlocking,
+
+ // SelfDeploying-v1 / Device-Only
+ AwaitingDeviceSetupProvisioning,
+ AwaitingDeviceOnlyEsp,
+
+ // WhiteGlove Part 1
+ WhiteGloveCandidate,
+ WhiteGloveSealed,
+
+ // WhiteGlove Part 2 (post-reboot user sign-in)
+ WhiteGloveAwaitingUserSignIn,
+ WhiteGloveCompletedPart2,
+
+ // Terminal
+ Completed,
+ Failed,
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SignalFact.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SignalFact.cs
new file mode 100644
index 00000000..507e3da5
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SignalFact.cs
@@ -0,0 +1,32 @@
+using System;
+
+namespace AutopilotMonitor.DecisionCore.State
+{
+ ///
+ /// Immutable value-with-provenance record. Plan §2.3.
+ /// points back at the DecisionSignal
+ /// that set the fact, enabling evidence trace in the Inspector.
+ ///
+ public sealed class SignalFact
+ {
+ public SignalFact(T value, long sourceSignalOrdinal)
+ {
+ if (sourceSignalOrdinal < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(sourceSignalOrdinal),
+ "SourceSignalOrdinal must be non-negative.");
+ }
+
+ Value = value;
+ SourceSignalOrdinal = sourceSignalOrdinal;
+ }
+
+ public T Value { get; }
+
+ public long SourceSignalOrdinal { get; }
+
+ public SignalFact With(T value, long sourceSignalOrdinal) =>
+ new SignalFact(value, sourceSignalOrdinal);
+ }
+}