refactor(decisioncore): add DecisionCore shared library scaffolding (M1)

New netstandard2.0 project src/Shared/AutopilotMonitor.DecisionCore/ with
14 scaffolding types across Signals/, State/, Classifiers/, Engine/.
Agent.Core and Functions reference the library without call-sites; the
full solution builds 0 errors, 0 warnings. DecisionEngine.Reduce is an
intentional NotImplementedException stub — M3 fills in the partial-class
reducer.

Invariants wired into the scaffolding:
 - Immutable DTOs (sealed + get-only + With-methods) per L.3
 - Pflicht-Evidence validation in Evidence ctor per L.5
 - KindSchemaVersion enforcement per L.6
 - AssemblyVersion 2.0.0.0 as ReducerVersion source per §2.10

Architecture principles L.1–L.18 extracted to
.claude/architecture-principles.md as persistent Claude-session context.

Plan: plans/REFACTOR_AGENT_V2.md §4 M1. Zero runtime risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 13:29:19 +02:00
parent 6f758ea0f1
commit 830a27bb70
24 changed files with 1166 additions and 2 deletions
+145
View File
@@ -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.
+17 -2
View File
@@ -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
@@ -16,6 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
<ProjectReference Include="..\..\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj" />
</ItemGroup>
<ItemGroup>
@@ -30,6 +30,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
<ProjectReference Include="..\..\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AssemblyVersion>2.0.0.0</AssemblyVersion>
<FileVersion>2.0.0.0</FileVersion>
<Description>
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).
</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Classifiers
{
/// <summary>
/// Immutable verdict record produced by an <see cref="IClassifier"/>. Plan §2.4.
/// <para>
/// Emitted back into the signal log as a synthetic
/// <c>ClassifierVerdictIssued</c> signal (L.8) and attached to the corresponding
/// <c>DecisionTransition.ClassifierVerdictJson</c>.
/// </para>
/// </summary>
public sealed class ClassifierVerdict
{
public ClassifierVerdict(
string classifierId,
HypothesisLevel level,
int score,
IReadOnlyList<string> 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<string>();
Reason = reason ?? string.Empty;
InputHash = inputHash;
}
public string ClassifierId { get; }
public HypothesisLevel Level { get; }
public int Score { get; }
public IReadOnlyList<string> ContributingFactors { get; }
public string Reason { get; }
/// <summary>SHA256-based hex hash (16 chars) over classifier-specific snapshot. Plan §2.4 — anti-loop.</summary>
public string InputHash { get; }
}
}
@@ -0,0 +1,29 @@
using System;
namespace AutopilotMonitor.DecisionCore.Classifiers
{
/// <summary>
/// Kernel classifier service. Plan §2.4 / L.8.
/// <para>
/// Implementations are pure: <c>Classify(snapshot) → ClassifierVerdict</c>.
/// Anti-loop via <see cref="ClassifierVerdict.InputHash"/> — the reducer does not re-invoke
/// a classifier whose last verdict matches the current snapshot hash.
/// </para>
/// <para>
/// First implementations shipped in M3: <c>WhiteGloveSealingClassifier</c>,
/// <c>WhiteGlovePart2CompletionClassifier</c>. Future (v11.1+): <c>EnrollmentFailureCauseClassifier</c>,
/// <c>AppInstallOutcomeClassifier</c>.
/// </para>
/// </summary>
public interface IClassifier
{
/// <summary>Stable identifier used in verdict records and journal references.</summary>
string Id { get; }
/// <summary>Concrete snapshot type the classifier expects. Enforced by the reducer.</summary>
Type SnapshotType { get; }
/// <summary>Run the classifier against a snapshot.</summary>
ClassifierVerdict Classify(object snapshot);
}
}
@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Engine
{
/// <summary>
/// Action kind an <see cref="DecisionEffect"/> requests the EffectRunner to execute. Plan §2.5 / §2.7b.
/// </summary>
public enum DecisionEffectKind
{
ScheduleDeadline,
CancelDeadline,
RunClassifier,
EmitEventTimelineEntry,
PersistSnapshot,
}
/// <summary>
/// Immutable effect record. Plan §2.5.
/// <para>
/// Effects are the <b>only</b> side-channel through which the reducer talks to the outside world.
/// The reducer itself is pure (<c>(old, signal) → (new, transition, effects)</c>); the EffectRunner
/// performs I/O (Deadlines, Classifier, Event-Emit, Persist) per plan §2.7b error strategy:
/// </para>
/// <list type="bullet">
/// <item><b>Transient</b> (retry, 100/400/1600 ms): <see cref="DecisionEffectKind.EmitEventTimelineEntry"/>, <see cref="DecisionEffectKind.PersistSnapshot"/></item>
/// <item><b>Critical</b> (session abort on register failure): <see cref="DecisionEffectKind.ScheduleDeadline"/>, <see cref="DecisionEffectKind.CancelDeadline"/></item>
/// <item><b>Optional</b> (best-effort, no abort): <see cref="DecisionEffectKind.RunClassifier"/></item>
/// </list>
/// </summary>
public sealed class DecisionEffect
{
public DecisionEffect(
DecisionEffectKind kind,
IReadOnlyDictionary<string, string>? 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<string, string>? Parameters { get; }
/// <summary>Set for <see cref="DecisionEffectKind.ScheduleDeadline"/>.</summary>
public ActiveDeadline? Deadline { get; }
/// <summary>Set for <see cref="DecisionEffectKind.CancelDeadline"/>.</summary>
public string? CancelDeadlineName { get; }
/// <summary>Set for <see cref="DecisionEffectKind.RunClassifier"/>.</summary>
public string? ClassifierId { get; }
/// <summary>Set for <see cref="DecisionEffectKind.RunClassifier"/> — classifier-specific snapshot object.</summary>
public object? ClassifierSnapshot { get; }
}
}
@@ -0,0 +1,47 @@
using System;
using System.Reflection;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Engine
{
/// <summary>
/// Kernel stub for the Decision Engine. Plan §2.5 + M1 gate.
/// <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").
/// </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>.
/// </para>
/// </summary>
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).");
}
}
}
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Engine
{
/// <summary>
/// Return value of a single reducer call. Plan §2.3 contract:
/// <c>(newState, transition, effects) = engine.Reduce(oldState, signal)</c>.
/// </summary>
public sealed class DecisionStep
{
public DecisionStep(
DecisionState newState,
DecisionTransition transition,
IReadOnlyList<DecisionEffect> 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<DecisionEffect> Effects { get; }
}
}
@@ -0,0 +1,113 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Engine
{
/// <summary>
/// Immutable journal record for a single reducer step. Plan §2.8.
/// <para>
/// <b>Taken=true</b>: a state-changing transition. <b>Taken=false</b>: 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).
/// </para>
/// <para>
/// Cross-type correlation: <see cref="SignalOrdinalRef"/> links to the triggering signal,
/// <see cref="EmittedEventSequences"/> lists Event.Sequence values of any timeline events
/// this step emitted, and <see cref="SessionTraceOrdinal"/> is the session-wide monotonic
/// counter shared across Event / Signal / Transition (plan §2.2 correlation IDs).
/// </para>
/// </summary>
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<GuardReport>? guards = null,
IReadOnlyList<long>? 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<GuardReport>();
EmittedEventSequences = emittedEventSequences ?? Array.Empty<long>();
ClassifierVerdict = classifierVerdict;
ErrorMessage = errorMessage;
StackTraceHash = stackTraceHash;
}
public int StepIndex { get; }
public long SessionTraceOrdinal { get; }
/// <summary>Plan §2.2 cross-reference — which signal caused this transition.</summary>
public long SignalOrdinalRef { get; }
public DateTime OccurredAtUtc { get; }
public string Trigger { get; }
public SessionStage FromStage { get; }
public SessionStage ToStage { get; }
public bool Taken { get; }
/// <summary>
/// Non-null when <see cref="Taken"/> is false. Plan §2.5 also uses well-known reasons:
/// <c>"reducer_exception"</c>, <c>"effect_failure"</c>, <c>"hybrid_reboot_gate_blocking"</c>, …
/// </summary>
public string? DeadEndReason { get; }
public string ReducerVersion { get; }
public IReadOnlyList<GuardReport> Guards { get; }
/// <summary>Plan §2.2 cross-reference — Event.Sequence values this transition emitted as effects.</summary>
public IReadOnlyList<long> EmittedEventSequences { get; }
public ClassifierVerdict? ClassifierVerdict { get; }
/// <summary>Set when <see cref="DeadEndReason"/> == "reducer_exception" (plan §2.5 fail-safe).</summary>
public string? ErrorMessage { get; }
/// <summary>SHA256-short hash of the original stack trace. Prevents unbounded PII in the journal.</summary>
public string? StackTraceHash { get; }
}
}
@@ -0,0 +1,31 @@
using System;
namespace AutopilotMonitor.DecisionCore.Engine
{
/// <summary>
/// Guard-evaluation record emitted during a reducer step. Plan §2.5 / §2.8 (Journal
/// <c>GuardsJson</c>). A taken transition may have zero or more guard evaluations;
/// a dead-end transition has at least one <c>Passed=false</c> guard that explains
/// the rejection.
/// </summary>
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; }
}
}
@@ -0,0 +1,26 @@
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Engine
{
/// <summary>
/// Pure reducer interface. Plan §2.5 / L.2 / L.3.
/// <para>
/// <b>Contract</b>: <paramref name="oldState"/> must be treated as immutable and is never
/// modified. The returned <see cref="DecisionStep.NewState"/> is a fresh instance.
/// Signal-log determinism (L.5): the same persisted signal stream always produces the
/// same journal and the same terminal outcome.
/// </para>
/// </summary>
public interface IDecisionEngine
{
/// <summary>ReducerVersion stamp written into each journal transition. Drift-detection in §2.10.</summary>
string ReducerVersion { get; }
/// <summary>
/// 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.
/// </summary>
DecisionStep Reduce(DecisionState oldState, DecisionSignal signal);
}
}
@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.DecisionCore.Signals
{
/// <summary>
/// Immutable input unit to the Decision Engine. Plan §2.2.
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class DecisionSignal
{
public DecisionSignal(
long sessionSignalOrdinal,
long sessionTraceOrdinal,
DecisionSignalKind kind,
int kindSchemaVersion,
DateTime occurredAtUtc,
string sourceOrigin,
Evidence evidence,
IReadOnlyDictionary<string, string>? 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;
}
/// <summary>Monotonic per SignalLog; RowKey for Azure Signals table.</summary>
public long SessionSignalOrdinal { get; }
/// <summary>Session-wide monotonic across all telemetry types (Event + Signal + Transition). Inspector correlation only.</summary>
public long SessionTraceOrdinal { get; }
public DecisionSignalKind Kind { get; }
/// <summary>Plan §2.2 L.6 — reducer dispatches on (Kind, KindSchemaVersion).</summary>
public int KindSchemaVersion { get; }
public DateTime OccurredAtUtc { get; }
public string SourceOrigin { get; }
public Evidence Evidence { get; }
public IReadOnlyDictionary<string, string>? Payload { get; }
}
}
@@ -0,0 +1,45 @@
namespace AutopilotMonitor.DecisionCore.Signals
{
/// <summary>
/// All signal kinds consumed by the Decision Engine.
/// Plan §2.2. Every kind is versioned via <see cref="DecisionSignal.KindSchemaVersion"/>;
/// reducer handlers dispatch on (Kind, SchemaVersion). New kinds or version bumps
/// require a replay fixture in <c>tests/fixtures/signal-kinds/{kind}-v{n}.json</c>
/// — missing fixture = merge block.
/// </summary>
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,
}
}
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.DecisionCore.Signals
{
/// <summary>
/// Immutable evidence record attached to every <see cref="DecisionSignal"/>.
/// Plan §2.2 — pflichtfeld-validation in the signal adapter; signal without
/// evidence = bug. Per-kind mandatory fields:
/// <list type="bullet">
/// <item>Raw: <see cref="Kind"/>, <see cref="Identifier"/>, <see cref="Summary"/></item>
/// <item>Derived: all Raw fields + non-empty <see cref="DerivationInputs"/></item>
/// <item>Synthetic: <see cref="Kind"/>, <see cref="Identifier"/>, <see cref="Summary"/></item>
/// </list>
/// </summary>
public sealed class Evidence
{
public Evidence(
EvidenceKind kind,
string identifier,
string summary,
string? rawPointer = null,
IReadOnlyDictionary<string, string>? 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; }
/// <summary>
/// Raw: RecordId / RegistryPath / LogLineHash.
/// Derived: detectorId + version (e.g. "desktop-arrival-detector-v1").
/// Synthetic: correlationId (e.g. "deadline:hello_safety:fired").
/// </summary>
public string Identifier { get; }
/// <summary>UI-friendly summary, ≤256 chars (truncated on construction).</summary>
public string Summary { get; }
/// <summary>Optional blob key for full raw payload retrieval.</summary>
public string? RawPointer { get; }
/// <summary>Derived-only: which raw observations fed the detector.</summary>
public IReadOnlyDictionary<string, string>? DerivationInputs { get; }
}
}
@@ -0,0 +1,17 @@
namespace AutopilotMonitor.DecisionCore.Signals
{
/// <summary>
/// Classification of a signal's evidence origin. Plan §2.2.
/// </summary>
public enum EvidenceKind
{
/// <summary>Direct observation from a raw source (event log, registry, IME line).</summary>
Raw,
/// <summary>Produced by a detector from multiple raw inputs. Requires <c>DerivationInputs</c>.</summary>
Derived,
/// <summary>Engine-internal synthesis (deadline fired, classifier verdict, session recovered).</summary>
Synthetic,
}
}
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// 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 <see cref="DecisionState.Deadlines"/>; <c>remaining = DueAtUtc - clock.UtcNow</c>.
/// </summary>
public sealed class ActiveDeadline
{
public ActiveDeadline(
string name,
DateTime dueAtUtc,
DecisionSignalKind firesSignalKind,
IReadOnlyDictionary<string, string>? firesPayload = null)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentException("Deadline Name is mandatory.", nameof(name));
}
Name = name;
DueAtUtc = dueAtUtc;
FiresSignalKind = firesSignalKind;
FiresPayload = firesPayload;
}
/// <summary>Unique deadline name within a session (e.g. "hello_safety", "part2_safety").</summary>
public string Name { get; }
public DateTime DueAtUtc { get; }
public DecisionSignalKind FiresSignalKind { get; }
public IReadOnlyDictionary<string, string>? FiresPayload { get; }
}
}
@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Immutable snapshot of the engine-visible session state. Plan §2.3.
/// <para>
/// <b>Invariant</b>: all DTOs in DecisionCore are immutable value objects.
/// "Change" is a new instance via <c>With…</c>-methods — no in-place mutation.
/// Reducer contract: <c>(newState, transition, effects) = engine.Reduce(oldState, signal)</c>.
/// </para>
/// <para>
/// Agent-process lifecycle flags (crash, admin actions, boot-time, heartbeat) live
/// in <c>agent-lifecycle.json</c>, not here (L.11 separation).
/// </para>
/// </summary>
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<EnrollmentPhase>? currentEnrollmentPhase,
SignalFact<DateTime>? deviceSetupEnteredUtc,
SignalFact<DateTime>? accountSetupEnteredUtc,
SignalFact<DateTime>? finalizingEnteredUtc,
SignalFact<DateTime>? espFinalExitUtc,
SignalFact<DateTime>? desktopArrivedUtc,
SignalFact<DateTime>? helloResolvedUtc,
SignalFact<DateTime>? systemRebootUtc,
SignalFact<string>? helloOutcome,
SignalFact<bool>? aadJoinedWithUser,
SignalFact<string>? imeMatchedPatternId,
IReadOnlyList<ActiveDeadline> 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; }
/// <summary>Engine-stage — what the reducer is currently waiting on.</summary>
public SessionStage Stage { get; }
/// <summary>Null when the session is non-terminal.</summary>
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<EnrollmentPhase>? CurrentEnrollmentPhase { get; }
public SignalFact<DateTime>? DeviceSetupEnteredUtc { get; }
public SignalFact<DateTime>? AccountSetupEnteredUtc { get; }
public SignalFact<DateTime>? FinalizingEnteredUtc { get; }
// --- Signal-induced facts (with source ordinal for evidence trace) ---
public SignalFact<DateTime>? EspFinalExitUtc { get; }
public SignalFact<DateTime>? DesktopArrivedUtc { get; }
public SignalFact<DateTime>? HelloResolvedUtc { get; }
public SignalFact<DateTime>? SystemRebootUtc { get; }
public SignalFact<string>? HelloOutcome { get; }
public SignalFact<bool>? AadJoinedWithUser { get; }
public SignalFact<string>? ImeMatchedPatternId { get; }
public IReadOnlyList<ActiveDeadline> Deadlines { get; }
public long LastAppliedSignalOrdinal { get; }
public int StepIndex { get; }
public string SchemaVersion { get; }
/// <summary>
/// Construct the initial non-terminal state for a new session.
/// Used by the reducer's <c>SessionStarted</c> handler in M3.
/// </summary>
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<ActiveDeadline>(),
lastAppliedSignalOrdinal: -1,
stepIndex: 0);
}
}
@@ -0,0 +1,65 @@
using System;
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Immutable engine hypothesis record. Plan §2.3.
/// Replaces boolean flag state in trackers with an explicit
/// level/score/reason/evidence-trace tuple.
/// </summary>
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; }
/// <summary>0..100 — classifier score or reducer-assigned confidence.</summary>
public int Score { get; }
public DateTime LastUpdatedUtc { get; }
/// <summary>Journal reference to the classifier verdict that last touched this hypothesis, if any.</summary>
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);
}
}
@@ -0,0 +1,20 @@
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Confidence levels for an engine hypothesis. Plan §2.3.
/// </summary>
public enum HypothesisLevel
{
Unknown = 0,
Weak = 1,
Strong = 2,
Confirmed = 3,
Rejected = 4,
/// <summary>
/// Plan §2.7b — Classifier exception / best-effort failure.
/// Hypothesis keeps its prior level; verdict recorded as Inconclusive, no abort.
/// </summary>
Inconclusive = 5,
}
}
@@ -0,0 +1,17 @@
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Terminal outcome of a session. Plan §2.3.
/// Non-null only when <see cref="SessionStage"/> is a terminal stage
/// (<c>Completed</c>, <c>Failed</c>, or <c>WhiteGloveCompletedPart2</c>).
/// </summary>
public enum SessionOutcome
{
Unknown = 0,
EnrollmentComplete,
EnrollmentFailed,
WhiteGlovePart1Sealed,
WhiteGlovePart2Complete,
Aborted,
}
}
@@ -0,0 +1,41 @@
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Engine stage — what the engine is currently waiting on. Plan §2.3.
/// Distinct from <c>EnrollmentPhase</c> (end-user reality) and from <c>Hypothesis</c> (engine guesses).
/// <para>
/// 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
/// <c>UnknownFallbackEnumConverter</c> (§2.15 L.14).
/// </para>
/// </summary>
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,
}
}
@@ -0,0 +1,32 @@
using System;
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Immutable value-with-provenance record. Plan §2.3.
/// <see cref="SourceSignalOrdinal"/> points back at the <c>DecisionSignal</c>
/// that set the fact, enabling evidence trace in the Inspector.
/// </summary>
public sealed class SignalFact<T>
{
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<T> With(T value, long sourceSignalOrdinal) =>
new SignalFact<T>(value, sourceSignalOrdinal);
}
}