From 830a27bb704f3b829d854b6bd85a92e4dc704ba1 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 20 Apr 2026 13:29:19 +0200 Subject: [PATCH] refactor(decisioncore): add DecisionCore shared library scaffolding (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/architecture-principles.md | 145 ++++++++++++++++ AutopilotMonitor.sln | 19 +- .../AutopilotMonitor.Agent.Core.csproj | 1 + .../AutopilotMonitor.Functions.csproj | 1 + .../AutopilotMonitor.DecisionCore.csproj | 21 +++ .../Classifiers/ClassifierVerdict.cs | 63 +++++++ .../Classifiers/IClassifier.cs | 29 ++++ .../Engine/DecisionEffect.cs | 66 +++++++ .../Engine/DecisionEngine.cs | 47 +++++ .../Engine/DecisionStep.cs | 29 ++++ .../Engine/DecisionTransition.cs | 113 ++++++++++++ .../Engine/GuardReport.cs | 31 ++++ .../Engine/IDecisionEngine.cs | 26 +++ .../Signals/DecisionSignal.cs | 69 ++++++++ .../Signals/DecisionSignalKind.cs | 45 +++++ .../Signals/Evidence.cs | 67 +++++++ .../Signals/EvidenceKind.cs | 17 ++ .../State/ActiveDeadline.cs | 41 +++++ .../State/DecisionState.cs | 163 ++++++++++++++++++ .../State/Hypothesis.cs | 65 +++++++ .../State/HypothesisLevel.cs | 20 +++ .../State/SessionOutcome.cs | 17 ++ .../State/SessionStage.cs | 41 +++++ .../State/SignalFact.cs | 32 ++++ 24 files changed, 1166 insertions(+), 2 deletions(-) create mode 100644 .claude/architecture-principles.md create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Classifiers/ClassifierVerdict.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifier.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEffect.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStep.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionTransition.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/GuardReport.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/IDecisionEngine.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignal.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Signals/Evidence.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Signals/EvidenceKind.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/State/ActiveDeadline.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/State/Hypothesis.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/State/HypothesisLevel.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/State/SessionStage.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/State/SignalFact.cs 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); + } +}