mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
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:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user