diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 1989e0ad..0e3a2ff4 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -51,6 +51,8 @@ You are operating within a constrained context window and strict system prompts.
- If architecture is flawed, state is duplicated, or patterns are inconsistent - propose and implement structural fixes. Ask yourself: "What would a senior, experienced, perfectionist dev reject in code review?" Fix all of it.
+Codex will review your output once you are done!
+
## Task Management
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/.gitignore b/.gitignore
index f5611b6b..89bfc779 100644
--- a/.gitignore
+++ b/.gitignore
@@ -109,16 +109,26 @@ tmpclaude-*-cwd
# No individual file ignores needed — the submodule handles everything
# Deployment scripts (local only)
+scripts/Deployment/build_and_upload_debug_agent_build_dev.ps1
scripts/Deployment/build_and_upload_debug_agent_build.ps1
scripts/Deployment/build_and_upload_release_agent_build.ps1
+# V2 Deployment scripts (tokens + SAS URLs, analog to Legacy) — plan §4 M2
+scripts/Deployment/V2/
+
+# M2 — local raw session fixtures before anonymization (plan §4 M2)
+tests/fixtures/enrollment-sessions-local/*
+!tests/fixtures/enrollment-sessions-local/.gitkeep
+!tests/fixtures/enrollment-sessions-local/README.md
+
.dotnet-home/
.vscode/
TODO.txt
tools/orphan-analysis.py
-tasks/todo.md
+tasks/
# Azure Table cleanup backups (dry-run exports + delete manifests)
# Produced by /cleanup-table skill — never commit deletion payloads
db-backups/
+plans/REFACTOR_AGENT_V2.md
diff --git a/AutopilotMonitor.sln b/AutopilotMonitor.sln
index 1ddc606d..7044c7e5 100644
--- a/AutopilotMonitor.sln
+++ b/AutopilotMonitor.sln
@@ -25,6 +25,16 @@ 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
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2", "src\Agent\AutopilotMonitor.Agent.V2\AutopilotMonitor.Agent.V2.csproj", "{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2.Core", "src\Agent\AutopilotMonitor.Agent.V2.Core\AutopilotMonitor.Agent.V2.Core.csproj", "{22CB0E75-793A-4FC9-ADE4-48580BCE6321}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.DecisionCore.Tests", "src\Agent\AutopilotMonitor.DecisionCore.Tests\AutopilotMonitor.DecisionCore.Tests.csproj", "{11B36E5C-B512-4D1E-A76B-E5AD866B033C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2.Core.Tests", "src\Agent\AutopilotMonitor.Agent.V2.Core.Tests\AutopilotMonitor.Agent.V2.Core.Tests.csproj", "{95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -119,6 +129,66 @@ 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
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x64.Build.0 = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x86.Build.0 = Debug|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x64.ActiveCfg = Release|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x64.Build.0 = Release|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x86.ActiveCfg = Release|Any CPU
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x86.Build.0 = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x64.Build.0 = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x86.Build.0 = Debug|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|Any CPU.Build.0 = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x64.ActiveCfg = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x64.Build.0 = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x86.ActiveCfg = Release|Any CPU
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x86.Build.0 = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x64.Build.0 = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x86.Build.0 = Debug|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x64.ActiveCfg = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x64.Build.0 = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x86.ActiveCfg = Release|Any CPU
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x86.Build.0 = Release|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|x64.Build.0 = Debug|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|x86.Build.0 = Debug|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|x64.ActiveCfg = Release|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|x64.Build.0 = Release|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|x86.ActiveCfg = Release|Any CPU
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -129,10 +199,15 @@ 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}
+ {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054} = {096B6098-F141-7114-B090-72D1899D8853}
+ {22CB0E75-793A-4FC9-ADE4-48580BCE6321} = {096B6098-F141-7114-B090-72D1899D8853}
+ {11B36E5C-B512-4D1E-A76B-E5AD866B033C} = {096B6098-F141-7114-B090-72D1899D8853}
+ {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0} = {096B6098-F141-7114-B090-72D1899D8853}
EndGlobalSection
EndGlobal
diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1
new file mode 100644
index 00000000..062cdd7d
--- /dev/null
+++ b/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1
@@ -0,0 +1,278 @@
+<#
+.SYNOPSIS
+ DEV variant of the bootstrap script to deploy and start the Autopilot Monitor agent.
+
+.DESCRIPTION
+ Funktionally identical to Install-AutopilotMonitor.ps1 with two differences:
+ 1. Default $AgentDownloadUrl points to AutopilotMonitor-Agent-dev.zip
+ 2. Integrity check is read from version-dev.json (NOT version.json)
+ This enables parallel lab testing of unreleased agent builds via a dedicated
+ Intune Platform Script assignment, without touching any production artefacts.
+
+ See Install-AutopilotMonitor.ps1 for the full behaviour (guards, SHA-256
+ integrity verification, scheduled task registration via --install, etc.).
+
+.PARAMETER AgentDownloadUrl
+ URL to download the DEV agent binaries from (ZIP file). Defaults to
+ AutopilotMonitor-Agent-dev.zip in the same blob container as production.
+
+.NOTES
+ - ASCII-only. PowerShell 5.1 / IME AgentExecutor reads scripts without BOM
+ as ANSI and corrupts multi-byte chars.
+ - Guards are intentionally identical to Prod bootstrapper so lab behaviour
+ matches production behaviour on fresh images.
+
+.CHANGELOG
+ 2026-04-20 v2.0-pre-dev Forked from Install-AutopilotMonitor-Dev.ps1 v1.1-dev for V2-Agent.
+ Default URL -> AutopilotMonitor-Agent-V2-dev.zip,
+ integrity file -> version-v2-dev.json,
+ agent exe -> AutopilotMonitor.Agent.V2.exe.
+ Plan: plans/REFACTOR_AGENT_V2.md section 4 M2.
+ 2026-04-14 v1.1-dev Initial DEV variant forked from Install-AutopilotMonitor.ps1 v1.1.
+ Default URL -> AutopilotMonitor-Agent-dev.zip, integrity file -> version-dev.json.
+#>
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $false)]
+ [string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-V2-dev.zip",
+
+ [Parameter(Mandatory = $false)]
+ [int]$MaxBootstrapWindowHours = 12
+)
+
+# Script version (bump on meaningful changes; see .CHANGELOG above)
+$ScriptVersion = "2.0-pre-dev"
+
+# Configuration - Everything in ProgramData for easy cleanup
+$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
+$AgentBinPath = "$AgentBasePath\Agent"
+$AgentLogPath = "$AgentBasePath\Logs"
+$LogFile = "$AgentLogPath\bootstrap_agent.log"
+
+# Ensure directories exist
+New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+
+function Write-Log {
+ param([string]$Message)
+ $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
+ $logMessage = "[$timestamp] $Message"
+ Write-Output $logMessage
+ Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
+}
+
+function Get-FileMd5Base64 {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Path
+ )
+
+ $md5 = [System.Security.Cryptography.MD5]::Create()
+ try {
+ $stream = [System.IO.File]::OpenRead($Path)
+ try {
+ $hashBytes = $md5.ComputeHash($stream)
+ }
+ finally {
+ $stream.Dispose()
+ }
+
+ return [Convert]::ToBase64String($hashBytes)
+ }
+ finally {
+ $md5.Dispose()
+ }
+}
+
+try {
+ Write-Log "===== Autopilot Monitor Bootstrap Started (DEV) ====="
+ Write-Log "Bootstrap script version: v$ScriptVersion"
+ Write-Log "Agent download URL: $AgentDownloadUrl"
+
+ # -- Pre-flight: Multi-signal guard (identical to production bootstrapper) --
+ # Guard 1: Ghost re-installs (registry marker from previous deployment)
+ # Guard 2: Productive devices (real user profile exists -- WMI + filesystem)
+ # Guard 3: Productive devices (a real user has logged on before)
+ # Guard 4: Bootstrap window expired (device uptime > 12h without agent)
+ # Guard 5: Agent binary already present from a previous run
+
+ # Guard 1
+ $deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
+ if ($deployed) {
+ Write-Log "SKIP: Agent was previously deployed at $deployed."
+ exit 0
+ }
+
+ # Guard 2
+ $excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
+
+ $profileNames = @(
+ try {
+ Get-CimInstance Win32_UserProfile -ErrorAction Stop |
+ Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
+ ForEach-Object { Split-Path $_.LocalPath -Leaf }
+ } catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
+
+ (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
+ ) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
+ Select-Object -Unique
+
+ if ($profileNames) {
+ $names = ($profileNames | Select-Object -First 3) -join ', '
+ Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
+ exit 0
+ }
+
+ # Guard 3
+ $lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
+ if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
+ Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
+ exit 0
+ }
+
+ # Guard 4
+ $lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
+ $uptimeHours = ((Get-Date) - $lastBoot).TotalHours
+ Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
+ if ($uptimeHours -gt $MaxBootstrapWindowHours) {
+ Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
+ exit 0
+ }
+
+ # Guard 5
+ if (Test-Path $AgentBinPath) {
+ $existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
+ if ($existingAgent) {
+ Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
+ exit 0
+ }
+ }
+
+ Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
+
+ # Download and extract agent binaries
+ $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.V2.exe"
+
+ if (Test-Path $agentExePath) {
+ Write-Log "Agent already installed at $agentExePath"
+ }
+ else {
+ Write-Log "Downloading DEV agent from $AgentDownloadUrl..."
+
+ try {
+ # DEV: Integrity-File ist version-dev.json (im selben Container wie das -dev ZIP)
+ $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-v2-dev.json'
+ $expectedSha256 = $null
+
+ try {
+ Write-Log "Fetching version-dev.json from $versionJsonUrl for integrity verification..."
+ $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
+ if ($versionJsonResponse.sha256) {
+ $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
+ Write-Log "SHA-256 hash from version-dev.json: $expectedSha256 (version: $($versionJsonResponse.version))"
+ } else {
+ Write-Log "version-dev.json has no sha256 field - falling back to legacy MD5 check"
+ }
+ }
+ catch {
+ Write-Log "WARNING: Could not fetch version-dev.json - falling back to legacy MD5 check: $($_.Exception.Message)"
+ }
+
+ $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-V2-dev.zip"
+ $maxDownloadAttempts = 3
+ $downloadAttempt = 0
+ $downloadResponse = $null
+
+ do {
+ $downloadAttempt++
+ try {
+ Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
+ $downloadResponse = Invoke-WebRequest `
+ -Uri $AgentDownloadUrl `
+ -OutFile $zipPath `
+ -UseBasicParsing `
+ -TimeoutSec 30 `
+ -ErrorAction Stop `
+ -PassThru
+ Write-Log "Downloaded agent to $zipPath"
+ break
+ }
+ catch {
+ if ($downloadAttempt -ge $maxDownloadAttempts) {
+ throw
+ }
+
+ $retryDelaysInSeconds = @(2, 4, 8)
+ $retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
+ Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
+ Start-Sleep -Seconds $retryDelaySeconds
+ }
+ } while ($downloadAttempt -lt $maxDownloadAttempts)
+
+ if ($expectedSha256) {
+ $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
+ Write-Log "Validating SHA-256 hash against version-dev.json"
+ if ($actualSha256 -ne $expectedSha256) {
+ throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
+ }
+ Write-Log "SHA-256 integrity check passed"
+ }
+ else {
+ $expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
+ $expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
+ if ($expectedMd5 -notmatch '\S') {
+ Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
+ }
+ else {
+ $actualMd5 = Get-FileMd5Base64 -Path $zipPath
+ Write-Log "Validating Content-MD5 header against downloaded ZIP"
+ if ($actualMd5 -ne $expectedMd5) {
+ throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
+ }
+ Write-Log "MD5 integrity check passed"
+ }
+ }
+
+ Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
+ Write-Log "Extracted agent to $AgentBinPath"
+
+ Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
+ Write-Log "Cleaned up temporary files"
+
+ if (-not (Test-Path $agentExePath)) {
+ throw "Agent executable not found after extraction at $agentExePath"
+ }
+
+ Write-Log "DEV agent installation completed successfully"
+ }
+ catch {
+ Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
+ throw
+ }
+ }
+
+ Write-Log "Calling agent install mode (--install)..."
+ & $agentExePath --install
+ $installExitCode = $LASTEXITCODE
+ if ($installExitCode -ne 0) {
+ throw "Agent install failed with exit code $installExitCode"
+ }
+ Write-Log "Agent install mode completed successfully"
+ Write-Log "===== Bootstrap Completed Successfully (DEV) ====="
+
+ exit 0
+}
+catch {
+ Write-Log "===== Bootstrap FAILED (DEV) ====="
+ Write-Log "ERROR: $($_.Exception.Message)"
+ Write-Log "Stack trace: $($_.ScriptStackTrace)"
+ Write-Log "Please check log file: $LogFile"
+
+ $errMsg = "AutopilotMonitor bootstrap (DEV) failed: $($_.Exception.Message)"
+ if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
+ [Console]::Error.WriteLine($errMsg)
+
+ exit 1
+}
diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
new file mode 100644
index 00000000..4edec32d
--- /dev/null
+++ b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
@@ -0,0 +1,322 @@
+<#
+.SYNOPSIS
+ Bootstrap script to deploy and start the Autopilot Monitor agent.
+
+.DESCRIPTION
+ This script is designed to be deployed via Intune as a PowerShell Script during Autopilot.
+ It runs VERY EARLY in the enrollment process (first Intune action) and:
+ 1. Creates a unique session ID for this enrollment
+ 2. Downloads the monitoring agent binaries
+ 3. Verifies integrity via SHA-256 hash from version.json
+ 4. Installs agent binaries (agent uses built-in backend URL)
+ 5. Registers agent as Scheduled Task (runs on computer startup)
+ 6. Agent self-destructs when enrollment completes
+
+ INTEGRITY VERIFICATION:
+ The script downloads version.json from the same blob container as the agent ZIP.
+ If version.json contains a "sha256" field, the downloaded ZIP is verified against it
+ using SHA-256. This prevents tampering during download (MITM) and detects corrupted
+ downloads. If the hash does not match, installation is aborted.
+
+ For backward compatibility: if version.json does not contain a "sha256" field (older
+ builds), the script falls back to the legacy Content-MD5 header check.
+
+.PARAMETER AgentDownloadUrl
+ URL to download the agent binaries from (ZIP file)
+
+.EXAMPLE
+ .\Install-AutopilotMonitor.ps1
+ (Uses built-in backend URL from the agent)
+
+.NOTES
+ - Agent is temporary and auto-removes after enrollment
+ - Everything in C:\ProgramData\AutopilotMonitor (easy cleanup)
+ - One Registry key left after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed (prevents ghost re-installs)
+ - Scheduled Task survives reboots during enrollment
+ - SHA-256 integrity verification since v1.0.706+
+ - IMPORTANT: This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
+ PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
+
+.CHANGELOG
+ 2026-04-20 v2.0-pre Forked from Install-AutopilotMonitor.ps1 v1.1 for V2-Agent.
+ URL -> AutopilotMonitor-Agent-V2.zip, integrity file -> version-v2.json,
+ agent exe -> AutopilotMonitor.Agent.V2.exe. Same guards + install flow.
+ Plan: plans/REFACTOR_AGENT_V2.md section 4 M2.
+ 2026-04-09 v1.1 Introduced explicit script version and log it on startup
+ (all entries above were v1.0)
+ 2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard:
+ registry deployment marker, WMI/filesystem user profile detection,
+ lastloggedonUser set, and 12h bootstrap window.
+ 2026-03-30 Fixed non-ASCII characters (em-dashes, Unicode symbols) that broke
+ script parsing under PowerShell 5.1 / IME AgentExecutor
+ 2026-03-29 Hardened integrity check: SHA-256 verification via version.json
+ 2026-02-16 Extended OS age default threshold
+ 2026-02-13 Simplified bootstrapper, introduced --install parameter for agent
+ 2026-02-12 More robust download with integrity check (Content-MD5)
+ 2026-02-12 Enhanced pre-flight checks, boot time support
+ 2026-02-12 Added pre-flight check: skip if agent already installed
+ 2026-02-05 Initial version
+#>
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $false)]
+ [string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-V2.zip",
+
+ [Parameter(Mandatory = $false)]
+ [int]$MaxBootstrapWindowHours = 12
+)
+
+# Script version (bump on meaningful changes; see .CHANGELOG above)
+$ScriptVersion = "2.0-pre"
+
+# Configuration - Everything in ProgramData for easy cleanup
+$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
+$AgentBinPath = "$AgentBasePath\Agent"
+$AgentLogPath = "$AgentBasePath\Logs"
+$LogFile = "$AgentLogPath\bootstrap_agent.log"
+
+# Ensure directories exist
+New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+
+function Write-Log {
+ param([string]$Message)
+ $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
+ $logMessage = "[$timestamp] $Message"
+ Write-Output $logMessage
+ Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
+}
+
+function Get-FileMd5Base64 {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Path
+ )
+
+ $md5 = [System.Security.Cryptography.MD5]::Create()
+ try {
+ $stream = [System.IO.File]::OpenRead($Path)
+ try {
+ $hashBytes = $md5.ComputeHash($stream)
+ }
+ finally {
+ $stream.Dispose()
+ }
+
+ return [Convert]::ToBase64String($hashBytes)
+ }
+ finally {
+ $md5.Dispose()
+ }
+}
+
+try {
+ Write-Log "===== Autopilot Monitor Bootstrap Started ====="
+ Write-Log "Bootstrap script version: v$ScriptVersion"
+
+ # -- Pre-flight: Multi-signal guard to prevent installation on non-provisioning devices --
+ # Layered approach: each guard catches a different scenario.
+ # Guard 1: Ghost re-installs (registry marker from previous deployment)
+ # Guard 2: Productive devices (real user profile exists -- WMI + filesystem)
+ # Guard 3: Productive devices (a real user has logged on before)
+ # Guard 4: Bootstrap window expired (device uptime > 12h without agent)
+ # Guard 5: Agent binary already present from a previous run
+ # NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
+ # NOTE: explorer.exe is NOT used -- it runs even in early OOBE right after script execution.
+
+ # Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
+ $deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
+ if ($deployed) {
+ Write-Log "SKIP: Agent was previously deployed at $deployed."
+ exit 0
+ }
+
+ # Guard 2: No real user profile should exist yet (primary productive-device guard)
+ # Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability.
+ $excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
+
+ $profileNames = @(
+ # WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService
+ try {
+ Get-CimInstance Win32_UserProfile -ErrorAction Stop |
+ Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
+ ForEach-Object { Split-Path $_.LocalPath -Leaf }
+ } catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
+
+ # Filesystem view -- catches profiles WMI might miss
+ (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
+ ) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
+ Select-Object -Unique
+
+ if ($profileNames) {
+ $names = ($profileNames | Select-Object -First 3) -join ', '
+ Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
+ exit 0
+ }
+
+ # Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet
+ $lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
+ if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
+ Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
+ exit 0
+ }
+
+ # Guard 4: Bootstrap window check (no key, no user profiles, but device running too long)
+ # NOT "how long may enrollment take" -- agent handles that internally (6h emergency break).
+ # This is "how old can the OOBE state be before I no longer trust it for initial install".
+ # 12h bootstrap window vs 6h agent emergency break = consistent layered approach.
+ # Sleep/standby does NOT reset uptime -- only real boot/restart does.
+ $lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
+ $uptimeHours = ((Get-Date) - $lastBoot).TotalHours
+ Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
+ if ($uptimeHours -gt $MaxBootstrapWindowHours) {
+ Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
+ exit 0
+ }
+
+ # Guard 5: Is the agent already installed? (leftover from previous run)
+ if (Test-Path $AgentBinPath) {
+ $existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
+ if ($existingAgent) {
+ Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
+ exit 0
+ }
+ }
+
+ Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
+
+ # Download and extract agent binaries
+ $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.V2.exe"
+
+ if (Test-Path $agentExePath) {
+ Write-Log "Agent already installed at $agentExePath"
+ }
+ else {
+ Write-Log "Downloading agent from $AgentDownloadUrl..."
+
+ try {
+ # Derive version.json URL from the agent download URL (same blob container)
+ $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-v2.json'
+ $expectedSha256 = $null
+
+ # Download version.json for SHA-256 integrity verification
+ try {
+ Write-Log "Fetching version.json from $versionJsonUrl for integrity verification..."
+ $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
+ if ($versionJsonResponse.sha256) {
+ $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
+ Write-Log "SHA-256 hash from version.json: $expectedSha256 (version: $($versionJsonResponse.version))"
+ } else {
+ Write-Log "version.json has no sha256 field - falling back to legacy MD5 check (older build)"
+ }
+ }
+ catch {
+ Write-Log "WARNING: Could not fetch version.json - falling back to legacy MD5 check: $($_.Exception.Message)"
+ }
+
+ # Download agent ZIP
+ $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-V2.zip"
+ $maxDownloadAttempts = 3
+ $downloadAttempt = 0
+ $downloadResponse = $null
+
+ do {
+ $downloadAttempt++
+ try {
+ Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
+ $downloadResponse = Invoke-WebRequest `
+ -Uri $AgentDownloadUrl `
+ -OutFile $zipPath `
+ -UseBasicParsing `
+ -TimeoutSec 30 `
+ -ErrorAction Stop `
+ -PassThru
+ Write-Log "Downloaded agent to $zipPath"
+ break
+ }
+ catch {
+ if ($downloadAttempt -ge $maxDownloadAttempts) {
+ throw
+ }
+
+ $retryDelaysInSeconds = @(2, 4, 8)
+ $retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
+ Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
+ Start-Sleep -Seconds $retryDelaySeconds
+ }
+ } while ($downloadAttempt -lt $maxDownloadAttempts)
+
+ # Integrity check: SHA-256 (preferred) or legacy Content-MD5 (fallback)
+ if ($expectedSha256) {
+ $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
+ Write-Log "Validating SHA-256 hash against version.json"
+ if ($actualSha256 -ne $expectedSha256) {
+ throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
+ }
+ Write-Log "SHA-256 integrity check passed"
+ }
+ else {
+ # Legacy fallback: Content-MD5 header check
+ $expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
+ $expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
+ if ($expectedMd5 -notmatch '\S') {
+ Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
+ }
+ else {
+ $actualMd5 = Get-FileMd5Base64 -Path $zipPath
+ Write-Log "Validating Content-MD5 header against downloaded ZIP"
+ if ($actualMd5 -ne $expectedMd5) {
+ throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
+ }
+ Write-Log "MD5 integrity check passed"
+ }
+ }
+
+ # Extract to agent bin path
+ Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
+ Write-Log "Extracted agent to $AgentBinPath"
+
+ # Cleanup
+ Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
+ Write-Log "Cleaned up temporary files"
+
+ # Verify extraction
+ if (-not (Test-Path $agentExePath)) {
+ throw "Agent executable not found after extraction at $agentExePath"
+ }
+
+ Write-Log "Agent installation completed successfully"
+ }
+ catch {
+ Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
+ throw
+ }
+ }
+
+ # Let the agent install/deploy itself and manage its own Scheduled Task
+ Write-Log "Calling agent install mode (--install)..."
+ & $agentExePath --install
+ $installExitCode = $LASTEXITCODE
+ if ($installExitCode -ne 0) {
+ throw "Agent install failed with exit code $installExitCode"
+ }
+ Write-Log "Agent install mode completed successfully"
+ Write-Log "===== Bootstrap Completed Successfully ====="
+
+ exit 0
+}
+catch {
+ Write-Log "===== Bootstrap FAILED ====="
+ Write-Log "ERROR: $($_.Exception.Message)"
+ Write-Log "Stack trace: $($_.ScriptStackTrace)"
+ Write-Log "Please check log file: $LogFile"
+
+ # some errors are captured by Intune but not fully visible in the UI (truncated, no stack trace), so also write critical info to stderr for better visibility.
+ $errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
+ if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
+ [Console]::Error.WriteLine($errMsg)
+
+ exit 1
+}
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/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj
new file mode 100644
index 00000000..46dad5b2
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj
@@ -0,0 +1,36 @@
+
+
+
+
+ net48
+ latest
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TempDirectory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TempDirectory.cs
new file mode 100644
index 00000000..f681140d
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TempDirectory.cs
@@ -0,0 +1,40 @@
+using System;
+using System.IO;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
+{
+ ///
+ /// Disposable per-test temp directory. Deleted on even if
+ /// sub-files are still open (best-effort).
+ ///
+ public sealed class TempDirectory : IDisposable
+ {
+ public TempDirectory()
+ {
+ Path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ "apmon-v2-test-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path);
+ }
+
+ public string Path { get; }
+
+ public string File(string name) => System.IO.Path.Combine(Path, name);
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(Path))
+ {
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+ catch
+ {
+ // Best-effort cleanup — test-host may hold a handle on a file we tried to
+ // write to. Not worth failing a test on cleanup.
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TestSignals.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TestSignals.cs
new file mode 100644
index 00000000..ffd201db
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TestSignals.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Signals;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
+{
+ ///
+ /// Builder helpers for in tests — keeps call sites compact.
+ ///
+ internal static class TestSignals
+ {
+ public static DecisionSignal Raw(
+ long ordinal,
+ DecisionSignalKind kind = DecisionSignalKind.EspPhaseChanged,
+ DateTime? occurredAtUtc = null,
+ long? traceOrdinal = null,
+ string origin = "UnitTest",
+ IReadOnlyDictionary? payload = null)
+ {
+ var at = occurredAtUtc ?? new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+ return new DecisionSignal(
+ sessionSignalOrdinal: ordinal,
+ sessionTraceOrdinal: traceOrdinal ?? ordinal,
+ kind: kind,
+ kindSchemaVersion: 1,
+ occurredAtUtc: at,
+ sourceOrigin: origin,
+ evidence: new Evidence(
+ kind: EvidenceKind.Raw,
+ identifier: $"raw-{ordinal}",
+ summary: $"Raw signal {ordinal}",
+ rawPointer: null,
+ derivationInputs: null),
+ payload: payload);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/VirtualClock.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/VirtualClock.cs
new file mode 100644
index 00000000..cfe87a98
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/VirtualClock.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.DecisionCore.Engine;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
+{
+ ///
+ /// Deterministic for V2.Core tests.
+ /// 1:1-Copy aus DecisionCore.Tests/Harness/VirtualClock.cs — Plan §2.16
+ /// Copy-Not-Share-Prinzip zwischen Testprojekten (konsistent zur V2-Isolation).
+ ///
+ public sealed class VirtualClock : IClock
+ {
+ private DateTime _now;
+
+ public VirtualClock(DateTime initialUtcNow)
+ {
+ if (initialUtcNow.Kind != DateTimeKind.Utc && initialUtcNow.Kind != DateTimeKind.Unspecified)
+ {
+ throw new ArgumentException("VirtualClock expects UTC/unspecified DateTime.", nameof(initialUtcNow));
+ }
+ _now = DateTime.SpecifyKind(initialUtcNow, DateTimeKind.Utc);
+ }
+
+ public DateTime UtcNow => _now;
+
+ public Task Delay(TimeSpan delay, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Advance(delay);
+ return Task.CompletedTask;
+ }
+
+ public void SetUtcNow(DateTime utcNow)
+ {
+ utcNow = DateTime.SpecifyKind(utcNow, DateTimeKind.Utc);
+ if (utcNow < _now)
+ {
+ throw new InvalidOperationException(
+ $"VirtualClock cannot go backwards (current={_now:O}, requested={utcNow:O}).");
+ }
+ _now = utcNow;
+ }
+
+ public void Advance(TimeSpan delta)
+ {
+ if (delta < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(delta), "Advance delta must be non-negative.");
+ }
+ _now = _now.Add(delta);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/EnrollmentOrchestratorFixture.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/EnrollmentOrchestratorFixture.cs
new file mode 100644
index 00000000..21cdfe01
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/EnrollmentOrchestratorFixture.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Persistence;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
+using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
+{
+ ///
+ /// End-to-end integration-test fixture for the V2 Agent runtime.
+ /// Plan §4.x M4.4.5.g.
+ ///
+ /// Drives the real pipeline: DecisionEngine + DecisionStepProcessor + SignalIngress +
+ /// EffectRunner + Persistence (SignalLog/Journal/Snapshot) + TelemetryTransport + production
+ /// Classifiers (WhiteGloveSealingClassifier + WhiteGlovePart2CompletionClassifier).
+ ///
+ ///
+ /// System-boundary fakes only: HTTP upload (),
+ /// wall-clock (), file-system (isolated ).
+ /// No collectors are wired — tests drive signals directly via
+ /// / , which mirrors the Collector→Adapter→Ingress
+ /// path but lets the test control timing.
+ ///
+ ///
+ /// Deadline caveat: the real runs. Effect-scheduled
+ /// deadlines use wall-clock timers; fixture signals with DeadlineFired-Kind are
+ /// posted manually (same as the M3 ClassifierAwareReplayHarness) — the real timers
+ /// would fire in the wall-clock future, long after test shutdown.
+ ///
+ ///
+ internal sealed class EnrollmentOrchestratorFixture : IDisposable
+ {
+ public const string SessionId = "session-anon-integration";
+ public const string TenantId = "tenant-anon-integration";
+
+ public TempDirectory Tmp { get; } = new TempDirectory();
+ public VirtualClock Clock { get; }
+ public AgentLogger Logger { get; }
+ public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader();
+ public EnrollmentOrchestrator Orchestrator { get; }
+ public string StateDir { get; }
+ public string TransportDir { get; }
+
+ public EnrollmentOrchestratorFixture()
+ {
+ Clock = new VirtualClock(new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc));
+ Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
+ StateDir = Path.Combine(Tmp.Path, "State");
+ TransportDir = Path.Combine(Tmp.Path, "Transport");
+
+ // Uploader is scripted OK so terminal drains succeed.
+ Uploader.QueueOk(100);
+
+ var classifiers = new List
+ {
+ new WhiteGloveSealingClassifier(),
+ new WhiteGlovePart2CompletionClassifier(),
+ };
+
+ Orchestrator = new EnrollmentOrchestrator(
+ sessionId: SessionId,
+ tenantId: TenantId,
+ stateDirectory: StateDir,
+ transportDirectory: TransportDir,
+ clock: Clock,
+ logger: Logger,
+ uploader: Uploader,
+ classifiers: classifiers,
+ // No collectors — tests drive the Ingress directly.
+ componentFactory: null,
+ drainInterval: TimeSpan.FromDays(1),
+ terminalDrainTimeout: TimeSpan.FromSeconds(2));
+ }
+
+ public void Start() => Orchestrator.Start();
+
+ public void Stop() => Orchestrator.Stop();
+
+ ///
+ /// Post one signal via the IngressSink, mirroring the Collector→Adapter path.
+ ///
+ public void Post(DecisionSignal signal)
+ {
+ if (signal == null) throw new ArgumentNullException(nameof(signal));
+ Orchestrator.IngressSink.Post(
+ kind: signal.Kind,
+ occurredAtUtc: signal.OccurredAtUtc,
+ sourceOrigin: signal.SourceOrigin,
+ evidence: signal.Evidence,
+ payload: signal.Payload,
+ kindSchemaVersion: signal.KindSchemaVersion);
+ }
+
+ ///
+ /// Load an M3 fixture (e.g. "userdriven-happy-v1.jsonl") and replay every signal
+ /// in order. After each post, wait until:
+ ///
+ /// - processor.CurrentState.StepIndex has advanced — ApplyStep fully
+ /// completed (journal + effects + snapshot + state-forward)
+ /// - Ingress queue is empty — any ClassifierVerdictIssued posted by the
+ /// EffectRunner has been processed
+ ///
+ ///
+ /// Why not ? That counter is updated
+ /// inside SignalIngress.ProcessItem BEFORE the EffectRunner runs — so a wait
+ /// on LastOrdinal can release before any synthetic signal (e.g. a classifier verdict)
+ /// has even been posted. StepIndex on the other hand is only incremented after
+ /// DecisionStepProcessor.ApplyStep completes, by which time the EffectRunner has
+ /// synchronously posted any RunClassifier-triggered synthetic.
+ ///
+ ///
+ public void PostFixture(string fixtureFilename)
+ {
+ var signals = FixtureLoader.Load(fixtureFilename);
+ var ingress = GetIngress();
+
+ foreach (var sig in signals)
+ {
+ int stepIndexBefore = Orchestrator.CurrentState.StepIndex;
+ Post(sig);
+
+ if (!SpinWait.SpinUntil(
+ () => Orchestrator.CurrentState.StepIndex > stepIndexBefore
+ && ingress.ApproximateQueueLength == 0,
+ 5000))
+ {
+ throw new TimeoutException(
+ $"Fixture replay stalled after {sig.Kind} (stepIndex before={stepIndexBefore}, " +
+ $"now={Orchestrator.CurrentState.StepIndex}, stage={Orchestrator.CurrentState.Stage}, " +
+ $"queueLen={ingress.ApproximateQueueLength}). Fixture: {fixtureFilename}.");
+ }
+ }
+ }
+
+ public AutopilotMonitor.Agent.V2.Core.Orchestration.SignalIngress GetIngress()
+ {
+ var field = typeof(EnrollmentOrchestrator).GetField(
+ "_ingress",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ return (AutopilotMonitor.Agent.V2.Core.Orchestration.SignalIngress)field!.GetValue(Orchestrator)!;
+ }
+
+ /// Waits for to match any of .
+ public bool WaitForStage(int timeoutMs, params SessionStage[] stages)
+ {
+ var target = new HashSet(stages);
+ return SpinWait.SpinUntil(
+ () => target.Contains(Orchestrator.CurrentState.Stage),
+ timeoutMs);
+ }
+
+ public ISignalLogWriter GetSignalLog()
+ {
+ var field = typeof(EnrollmentOrchestrator).GetField(
+ "_signalLog",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ return (ISignalLogWriter)field!.GetValue(Orchestrator)!;
+ }
+
+ public IJournalWriter GetJournal()
+ {
+ var field = typeof(EnrollmentOrchestrator).GetField(
+ "_journal",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ return (IJournalWriter)field!.GetValue(Orchestrator)!;
+ }
+
+ public TelemetrySpool GetSpool()
+ {
+ var field = typeof(EnrollmentOrchestrator).GetField(
+ "_spool",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ return (TelemetrySpool)field!.GetValue(Orchestrator)!;
+ }
+
+ /// Returns the last journal transition (or null if empty).
+ public AutopilotMonitor.DecisionCore.Engine.DecisionTransition? LastTransition()
+ {
+ var all = GetJournal().ReadAll();
+ return all.Count == 0 ? null : all[all.Count - 1];
+ }
+
+ /// Returns all event-kind items that were enqueued for the backend (post-drain or not).
+ public IReadOnlyList AllEventItemsInSpool()
+ {
+ return GetSpool()
+ .Peek(int.MaxValue)
+ .Where(i => i.Kind == TelemetryItemKind.Event)
+ .ToArray();
+ }
+
+ public void Dispose()
+ {
+ try { Orchestrator.Dispose(); }
+ catch { /* best-effort */ }
+ Tmp.Dispose();
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/FixtureLoader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/FixtureLoader.cs
new file mode 100644
index 00000000..f3d45456
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/FixtureLoader.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using AutopilotMonitor.DecisionCore.Serialization;
+using AutopilotMonitor.DecisionCore.Signals;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
+{
+ ///
+ /// Load a DecisionSignal JSONL fixture from tests/fixtures/enrollment-sessions/.
+ /// 1:1-Copy aus DecisionCore.Tests/Harness/FixtureLoader.cs — Plan §2.16 Copy-Not-Share-
+ /// Prinzip für V2-Isolation.
+ ///
+ /// Comment lines starting with '#' and blank lines are ignored.
+ ///
+ ///
+ public static class FixtureLoader
+ {
+ public static IReadOnlyList LoadJsonl(string path)
+ {
+ var signals = new List();
+ foreach (var rawLine in File.ReadAllLines(path))
+ {
+ var line = rawLine.Trim();
+ if (line.Length == 0 || line[0] == '#') continue;
+ signals.Add(SignalSerializer.Deserialize(line));
+ }
+ return signals;
+ }
+
+ ///
+ /// Locate the tests/fixtures/enrollment-sessions directory relative to the test binary.
+ /// Walks up from looking for the solution file.
+ ///
+ public static string FixtureRoot()
+ {
+ var dir = AppContext.BaseDirectory;
+ for (int i = 0; i < 12; i++)
+ {
+ if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln")))
+ {
+ return Path.Combine(dir, "tests", "fixtures", "enrollment-sessions");
+ }
+ var parent = Directory.GetParent(dir)?.FullName;
+ if (parent == null || parent == dir) break;
+ dir = parent;
+ }
+ throw new DirectoryNotFoundException(
+ "Could not locate repo root (AutopilotMonitor.sln) walking up from " +
+ AppContext.BaseDirectory);
+ }
+
+ public static IReadOnlyList Load(string filename) =>
+ LoadJsonl(Path.Combine(FixtureRoot(), filename));
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ScenarioTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ScenarioTests.cs
new file mode 100644
index 00000000..68890be2
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ScenarioTests.cs
@@ -0,0 +1,272 @@
+using System.Linq;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
+{
+ ///
+ /// End-to-end runtime tests that drive the V2 Agent orchestrator through the same fixtures
+ /// the M3 ClassifierAwareReplayHarness uses. Release-Gate M4.4 (Plan §4.x M4.4.5.g).
+ ///
+ /// Each test loads a JSONL fixture from tests/fixtures/enrollment-sessions/, replays
+ /// all signals through , and asserts the final
+ /// + journal tail against the expected terminal shape. Unlike
+ /// the M3 harness, this exercises Persistence + EffectRunner + Classifiers + Telemetry in
+ /// a single live pipeline.
+ ///
+ ///
+ /// Scenario coverage mirrors AutopilotMonitor.DecisionCore.Tests/Scenarios/*:
+ /// UserDriven happy/hello-timeout, SelfDeploying happy/esp-unknown, WhiteGlove inline/
+ /// signal-correlated/anti-loop/false-positive, WhiteGlove Part-2 happy/stuck, Hybrid-Reboot,
+ /// ESP-Terminal-Failure, Late-AADJ. DevPrep-v2 variants land with M5 (no M3 fixture yet).
+ ///
+ ///
+ public sealed class ScenarioTests
+ {
+ private const int DefaultTerminalTimeoutMs = 10_000;
+
+ // ================================================================ 1) UserDriven-v1 Happy
+
+ [Fact]
+ public void Scenario01_UserDriven_Happy_reaches_Completed()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("userdriven-happy-v1.jsonl");
+
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed, SessionStage.Failed));
+ Assert.Equal(SessionStage.Completed, f.Orchestrator.CurrentState.Stage);
+ Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
+ Assert.Equal("Success", f.Orchestrator.CurrentState.HelloOutcome?.Value);
+
+ f.Stop();
+ }
+
+ // ================================================================ 2) UserDriven-v1 HelloTimeout
+
+ [Fact]
+ public void Scenario02_UserDriven_HelloTimeout_completes_with_timeout_outcome()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("userdriven-hello-timeout-v1.jsonl");
+
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
+ Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
+ Assert.Equal("Timeout", f.Orchestrator.CurrentState.HelloOutcome?.Value);
+
+ f.Stop();
+ }
+
+ // ================================================================ 3) SelfDeploying-v1 Happy
+
+ [Fact]
+ public void Scenario03_SelfDeploying_Happy_reaches_Completed()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("selfdeploying-happy-v1.jsonl");
+
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
+ Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
+
+ f.Stop();
+ }
+
+ // ================================================================ 4) SelfDeploying-v1 ESP-Exit-Unknown
+
+ [Fact]
+ public void Scenario04_SelfDeploying_EspExitUnknown_completes_via_provisioning()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("selfdeploying-esp-exit-unknown-v1.jsonl");
+
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
+ Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
+ // DeviceOnlyDeployment hypothesis should have climbed from Unknown → Strong/Confirmed.
+ Assert.NotEqual(HypothesisLevel.Unknown, f.Orchestrator.CurrentState.DeviceOnlyDeployment.Level);
+
+ f.Stop();
+ }
+
+ // ================================================================ 5) WhiteGlove Inline
+
+ [Fact]
+ public void Scenario05_WhiteGlove_Inline_seals_part1()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("whiteglove-inline-v1.jsonl");
+
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed));
+ Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level);
+
+ f.Stop();
+ }
+
+ // ================================================================ 6) WhiteGlove Signal-Correlated
+
+ [Fact]
+ public void Scenario06_WhiteGlove_SignalCorrelated_seals_after_second_signal()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("whiteglove-signal-correlated-v1.jsonl");
+
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed));
+ Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level);
+
+ f.Stop();
+ }
+
+ // ================================================================ 7) WhiteGlove Anti-Loop
+
+ [Fact]
+ public void Scenario07_WhiteGlove_AntiLoop_does_not_double_invoke_classifier()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("whiteglove-antiloop-v1.jsonl");
+
+ // Anti-Loop: after two classifier-ticks with unchanged inputs, second run is skipped.
+ // Verify we reached a state where at least one sealing verdict was produced.
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs,
+ SessionStage.WhiteGloveSealed,
+ SessionStage.Completed,
+ SessionStage.Failed,
+ SessionStage.EspDeviceSetup));
+
+ f.Stop();
+ }
+
+ // ================================================================ 8) WhiteGlove False-Positive (Late-AADJ)
+
+ [Fact]
+ public void Scenario08_WhiteGlove_FalsePositive_LateAADJ_falls_back_to_classic()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("whiteglove-false-positive-v1.jsonl");
+
+ // Late-AADJ rejects the WG hypothesis; session still completes via the classic path.
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
+ Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
+ Assert.NotEqual(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level);
+
+ f.Stop();
+ }
+
+ // ================================================================ 9) WhiteGlove Part-2 Happy
+
+ [Fact]
+ public void Scenario09_WhiteGlovePart2_Happy_completes_part2()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("whiteglove-part2-happy-v1.jsonl");
+
+ var reached = f.WaitForStage(
+ DefaultTerminalTimeoutMs,
+ SessionStage.WhiteGloveCompletedPart2,
+ SessionStage.Completed,
+ SessionStage.Failed);
+
+ Assert.True(reached,
+ $"Part-2 happy fixture did not reach a terminal stage within {DefaultTerminalTimeoutMs}ms; " +
+ $"stuck at {f.Orchestrator.CurrentState.Stage} (stepIndex={f.Orchestrator.CurrentState.StepIndex}). " +
+ $"Last transition: {f.LastTransition()?.Trigger}.");
+ Assert.Equal(SessionStage.WhiteGloveCompletedPart2, f.Orchestrator.CurrentState.Stage);
+ Assert.Equal(SessionOutcome.WhiteGlovePart2Complete, f.Orchestrator.CurrentState.Outcome);
+
+ f.Stop();
+ }
+
+ // ================================================================ 10) WhiteGlove Part-2 Stuck
+
+ [Fact]
+ public void Scenario10_WhiteGlovePart2_Stuck_fails_on_safety_deadline()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("whiteglove-part2-stuck-v1.jsonl");
+
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Failed));
+ Assert.Equal(SessionOutcome.EnrollmentFailed, f.Orchestrator.CurrentState.Outcome);
+
+ f.Stop();
+ }
+
+ // ================================================================ 11) Hybrid-Reboot
+
+ [Fact]
+ public void Scenario11_HybridReboot_preserves_stage_and_completes()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("hybrid-reboot-v1.jsonl");
+
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
+ Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
+ Assert.NotNull(f.Orchestrator.CurrentState.SystemRebootUtc);
+
+ f.Stop();
+ }
+
+ // ================================================================ 12) ESP-Terminal-Failure
+
+ [Fact]
+ public void Scenario12_EspTerminalFailure_fails_with_reason()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("esp-terminal-failure-v1.jsonl");
+
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Failed));
+ Assert.Equal(SessionOutcome.EnrollmentFailed, f.Orchestrator.CurrentState.Outcome);
+
+ var last = f.LastTransition();
+ Assert.NotNull(last);
+ Assert.Equal("EspTerminalFailure", last!.Trigger);
+
+ f.Stop();
+ }
+
+ // ================================================================ 13) Late-AADJ Standalone
+
+ [Fact]
+ public void Scenario13_LateAADJ_standalone_classic_completion()
+ {
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("late-aadj-v1.jsonl");
+
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
+ Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
+
+ f.Stop();
+ }
+
+ // ================================================================ Pipeline smoke (Telemetry)
+
+ [Fact]
+ public void Full_pipeline_enqueues_telemetry_items_for_terminal_events()
+ {
+ // Verifies that beyond state-shape, the EffectRunner emitted at least one
+ // EmitEventTimelineEntry effect (e.g. enrollment_complete) that reached the Spool.
+ using var f = new EnrollmentOrchestratorFixture();
+ f.Start();
+ f.PostFixture("userdriven-happy-v1.jsonl");
+
+ Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
+
+ var eventItems = f.AllEventItemsInSpool();
+ Assert.NotEmpty(eventItems);
+ // At least one item should be a terminal enrollment_complete event.
+ Assert.Contains(eventItems, i => i.PayloadJson.Contains("enrollment_complete"));
+
+ f.Stop();
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DeadlineSchedulerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DeadlineSchedulerTests.cs
new file mode 100644
index 00000000..2e93d917
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DeadlineSchedulerTests.cs
@@ -0,0 +1,192 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ [Collection("SerialThreading")]
+ public sealed class DeadlineSchedulerTests
+ {
+ // 5000ms cushion: 2000ms was racy under full-suite parallelism even after adding
+ // [Collection("SerialThreading")] — timer callbacks execute on the shared ThreadPool
+ // and can lag when the pool is saturated by unrelated test classes. Plan §4.x M4.5.c.
+ private const int DefaultWaitMs = 5000;
+
+ private static ActiveDeadline Deadline(string name, DateTime dueAt) =>
+ new ActiveDeadline(name, dueAt, DecisionSignalKind.DeadlineFired);
+
+ [Fact]
+ public void Past_due_deadline_fires_immediately_on_ThreadPool()
+ {
+ var now = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+ var clock = new VirtualClock(now);
+ using var sched = new DeadlineScheduler(clock);
+
+ using var fired = new ManualResetEventSlim(false);
+ ActiveDeadline? seen = null;
+ sched.Fired += (_, args) => { seen = args.Deadline; fired.Set(); };
+
+ var pastDue = Deadline("hello_safety", now.AddSeconds(-30));
+ sched.Schedule(pastDue);
+
+ Assert.True(fired.Wait(DefaultWaitMs), "Past-due deadline did not fire.");
+ Assert.Equal("hello_safety", seen!.Name);
+ Assert.False(sched.IsScheduled("hello_safety"));
+ }
+
+ [Fact]
+ public void Future_deadline_fires_after_elapsed_time()
+ {
+ var now = DateTime.UtcNow;
+ // Use SystemClock for a real wait so the wall-clock Timer ticks.
+ var clock = new AutopilotMonitor.DecisionCore.Engine.SystemClock();
+ using var sched = new DeadlineScheduler(clock);
+
+ using var fired = new ManualResetEventSlim(false);
+ sched.Fired += (_, _) => fired.Set();
+
+ var deadline = Deadline("short", DateTime.UtcNow.AddMilliseconds(80));
+ sched.Schedule(deadline);
+ Assert.True(sched.IsScheduled("short"));
+
+ Assert.True(fired.Wait(DefaultWaitMs), "Future deadline did not fire.");
+ Assert.False(sched.IsScheduled("short"));
+ }
+
+ [Fact]
+ public void Cancel_prevents_future_fire()
+ {
+ var clock = new AutopilotMonitor.DecisionCore.Engine.SystemClock();
+ using var sched = new DeadlineScheduler(clock);
+
+ int fireCount = 0;
+ sched.Fired += (_, _) => Interlocked.Increment(ref fireCount);
+
+ sched.Schedule(Deadline("cancel-me", DateTime.UtcNow.AddMilliseconds(200)));
+ Assert.True(sched.IsScheduled("cancel-me"));
+
+ sched.Cancel("cancel-me");
+ Assert.False(sched.IsScheduled("cancel-me"));
+
+ Thread.Sleep(400);
+ Assert.Equal(0, fireCount);
+ }
+
+ [Fact]
+ public void Cancel_unknown_name_is_noop()
+ {
+ using var sched = new DeadlineScheduler(new AutopilotMonitor.DecisionCore.Engine.SystemClock());
+ sched.Cancel("never-scheduled");
+ sched.Cancel(string.Empty);
+ // Just assert no exception.
+ }
+
+ [Fact]
+ public void Reschedule_same_name_replaces_old_timer()
+ {
+ // Plan §2.6 Schedule-semantics: same-name replaces.
+ var clock = new AutopilotMonitor.DecisionCore.Engine.SystemClock();
+ using var sched = new DeadlineScheduler(clock);
+
+ var firedNames = new List();
+ using var someFired = new ManualResetEventSlim(false);
+ sched.Fired += (_, args) =>
+ {
+ lock (firedNames) firedNames.Add(args.Deadline);
+ someFired.Set();
+ };
+
+ // First: far in the future (won't fire within the test window).
+ sched.Schedule(Deadline("classifier_tick", DateTime.UtcNow.AddSeconds(30)));
+ // Replace with past-due. Old timer must be cancelled before new fires.
+ var replacement = Deadline("classifier_tick", DateTime.UtcNow.AddSeconds(-1));
+ sched.Schedule(replacement);
+
+ Assert.True(someFired.Wait(DefaultWaitMs));
+
+ lock (firedNames)
+ {
+ Assert.Single(firedNames);
+ Assert.Equal("classifier_tick", firedNames[0].Name);
+ // Replacement's DueAtUtc differs from the original — prove the replacement fired.
+ Assert.Equal(replacement.DueAtUtc, firedNames[0].DueAtUtc);
+ }
+ }
+
+ [Fact]
+ public void ActiveDeadlines_snapshot_reflects_current_schedule()
+ {
+ var clock = new AutopilotMonitor.DecisionCore.Engine.SystemClock();
+ using var sched = new DeadlineScheduler(clock);
+
+ sched.Schedule(Deadline("a", DateTime.UtcNow.AddSeconds(30)));
+ sched.Schedule(Deadline("b", DateTime.UtcNow.AddSeconds(30)));
+
+ var snap = sched.ActiveDeadlines;
+ Assert.Equal(2, snap.Count);
+
+ sched.Cancel("a");
+ Assert.Single(sched.ActiveDeadlines);
+ }
+
+ [Fact]
+ public void RehydrateFromSnapshot_reschedules_each_deadline_past_due_fires_immediately()
+ {
+ // Plan §2.6 Restart-Recovery: past-due → sofort DeadlineFired.
+ var now = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+ var clock = new VirtualClock(now);
+ using var sched = new DeadlineScheduler(clock);
+
+ int fireCount = 0;
+ using var allFired = new CountdownEvent(2);
+ sched.Fired += (_, _) =>
+ {
+ Interlocked.Increment(ref fireCount);
+ allFired.Signal();
+ };
+
+ sched.RehydrateFromSnapshot(new[]
+ {
+ Deadline("expired_1", now.AddMinutes(-10)),
+ Deadline("expired_2", now.AddSeconds(-1)),
+ });
+
+ Assert.True(allFired.Wait(DefaultWaitMs));
+ Assert.Equal(2, fireCount);
+ Assert.Empty(sched.ActiveDeadlines);
+ }
+
+ [Fact]
+ public void Dispose_cancels_all_pending_timers()
+ {
+ var clock = new AutopilotMonitor.DecisionCore.Engine.SystemClock();
+ var sched = new DeadlineScheduler(clock);
+
+ int fireCount = 0;
+ sched.Fired += (_, _) => Interlocked.Increment(ref fireCount);
+
+ sched.Schedule(Deadline("a", DateTime.UtcNow.AddMilliseconds(150)));
+ sched.Schedule(Deadline("b", DateTime.UtcNow.AddMilliseconds(150)));
+
+ sched.Dispose();
+ Thread.Sleep(350);
+
+ Assert.Equal(0, fireCount);
+ }
+
+ [Fact]
+ public void Schedule_after_Dispose_throws()
+ {
+ var sched = new DeadlineScheduler(new AutopilotMonitor.DecisionCore.Engine.SystemClock());
+ sched.Dispose();
+
+ Assert.Throws(() =>
+ sched.Schedule(Deadline("x", DateTime.UtcNow.AddSeconds(1))));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs
new file mode 100644
index 00000000..2bfa84cd
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ ///
+ /// M4.6.β — fires the terminal-stage callback exactly
+ /// once, at the transition from a non-terminal to a terminal .
+ ///
+ public sealed class DecisionStepProcessorTerminalStageTests
+ {
+ private static DateTime At => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
+
+ private sealed class Rig : IDisposable
+ {
+ public TempDirectory Tmp { get; } = new TempDirectory();
+ public AgentLogger Logger { get; }
+ public FakeJournalWriter Journal { get; } = new FakeJournalWriter();
+ public FakeEffectRunner Effects { get; } = new FakeEffectRunner();
+ public FakeSnapshotPersistence Snapshot { get; } = new FakeSnapshotPersistence();
+ public FakeQuarantineSink Quarantine { get; } = new FakeQuarantineSink();
+
+ public Rig() { Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); }
+
+ public DecisionStepProcessor Build(DecisionState initial, Action? onTerminal) =>
+ new DecisionStepProcessor(
+ initialState: initial,
+ journal: Journal,
+ effectRunner: Effects,
+ snapshot: Snapshot,
+ quarantineSink: Quarantine,
+ logger: Logger,
+ quarantineThreshold: DecisionStepProcessor.DefaultQuarantineThreshold,
+ onTerminalStageReached: onTerminal);
+
+ public void Dispose() => Tmp.Dispose();
+ }
+
+ private static DecisionSignal Sig(long ordinal, DateTime at) =>
+ new DecisionSignal(
+ sessionSignalOrdinal: ordinal,
+ sessionTraceOrdinal: ordinal,
+ kind: DecisionSignalKind.SessionStarted,
+ kindSchemaVersion: 1,
+ occurredAtUtc: at,
+ sourceOrigin: "test",
+ evidence: new Evidence(EvidenceKind.Raw, $"raw-{ordinal}", $"evidence-{ordinal}"),
+ payload: null);
+
+ private static DecisionStep StepToStage(DecisionState oldState, SessionStage newStage, long signalOrdinal, DateTime at)
+ {
+ var builder = new DecisionStateBuilder(oldState) { Stage = newStage, StepIndex = oldState.StepIndex + 1 };
+ var newState = builder.Build();
+ var transition = new DecisionTransition(
+ stepIndex: newState.StepIndex,
+ sessionTraceOrdinal: signalOrdinal,
+ signalOrdinalRef: signalOrdinal,
+ occurredAtUtc: at,
+ trigger: "TestTerminalTrigger",
+ fromStage: oldState.Stage,
+ toStage: newStage,
+ taken: true,
+ deadEndReason: null,
+ reducerVersion: "2.0.0.0");
+ return new DecisionStep(newState, transition, Array.Empty());
+ }
+
+ [Theory]
+ [InlineData(SessionStage.Completed)]
+ [InlineData(SessionStage.Failed)]
+ [InlineData(SessionStage.WhiteGloveSealed)]
+ [InlineData(SessionStage.WhiteGloveCompletedPart2)]
+ public void Terminal_stage_transition_fires_callback_with_terminal_state(SessionStage terminal)
+ {
+ using var rig = new Rig();
+ DecisionState? captured = null;
+ var callCount = 0;
+ var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"),
+ onTerminal: state =>
+ {
+ Interlocked.Increment(ref callCount);
+ captured = state;
+ });
+
+ var step = StepToStage(sut.CurrentState, terminal, signalOrdinal: 1, at: At);
+ sut.ApplyStep(step, Sig(1, At));
+
+ Assert.Equal(1, callCount);
+ Assert.NotNull(captured);
+ Assert.Equal(terminal, captured!.Stage);
+ }
+
+ [Fact]
+ public void Terminal_callback_does_not_fire_for_non_terminal_transitions()
+ {
+ using var rig = new Rig();
+ var callCount = 0;
+ var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"),
+ onTerminal: _ => Interlocked.Increment(ref callCount));
+
+ var step = StepToStage(sut.CurrentState, SessionStage.AwaitingEspPhaseChange, signalOrdinal: 1, at: At);
+ sut.ApplyStep(step, Sig(1, At));
+
+ Assert.Equal(0, callCount);
+ }
+
+ [Fact]
+ public void Terminal_callback_fires_at_most_once_per_processor_instance()
+ {
+ using var rig = new Rig();
+ var callCount = 0;
+ var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"),
+ onTerminal: _ => Interlocked.Increment(ref callCount));
+
+ // Transition to Completed, then apply a second step that leaves the stage terminal.
+ var firstStep = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At);
+ sut.ApplyStep(firstStep, Sig(1, At));
+
+ // Synthetic second step — stage still terminal (same value).
+ var secondStep = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 2, at: At.AddSeconds(1));
+ sut.ApplyStep(secondStep, Sig(2, At.AddSeconds(1)));
+
+ Assert.Equal(1, callCount);
+ }
+
+ [Fact]
+ public void Terminal_callback_suppressed_when_initial_state_is_already_terminal()
+ {
+ // Recovery scenario: snapshot loaded a state whose stage is already terminal
+ // (e.g. crash after terminal transition but before Stop). The processor must not
+ // re-fire the callback on the first post-recovery step.
+ using var rig = new Rig();
+ var callCount = 0;
+ var recoveredState = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1"))
+ {
+ Stage = SessionStage.Completed,
+ }.Build();
+
+ var sut = rig.Build(recoveredState,
+ onTerminal: _ => Interlocked.Increment(ref callCount));
+
+ // No ApplyStep needed — the ctor already observed the terminal state. Even if we
+ // apply a follow-up terminal-preserving step, callback stays silent.
+ var step = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At);
+ sut.ApplyStep(step, Sig(1, At));
+
+ Assert.Equal(0, callCount);
+ }
+
+ [Fact]
+ public void Terminal_callback_null_is_supported()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), onTerminal: null);
+ var step = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At);
+
+ // Should not throw when callback is null.
+ sut.ApplyStep(step, Sig(1, At));
+ Assert.Equal(SessionStage.Completed, sut.CurrentState.Stage);
+ }
+
+ [Fact]
+ public void Terminal_callback_exception_is_swallowed_and_logged()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"),
+ onTerminal: _ => throw new InvalidOperationException("handler bug"));
+
+ var step = StepToStage(sut.CurrentState, SessionStage.Failed, signalOrdinal: 1, at: At);
+ // Must not bubble the handler's exception — the step is already committed.
+ sut.ApplyStep(step, Sig(1, At));
+ Assert.Equal(SessionStage.Failed, sut.CurrentState.Stage);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTests.cs
new file mode 100644
index 00000000..058e7c96
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTests.cs
@@ -0,0 +1,247 @@
+using System;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ public sealed class DecisionStepProcessorTests
+ {
+ private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+
+ ///
+ /// Rig wires the processor against in-memory fakes + a real
+ /// so we get genuine instances (ctor-validated transition +
+ /// effects) without duplicating the reducer shape in test builders.
+ ///
+ private sealed class Rig : IDisposable
+ {
+ public TempDirectory Tmp { get; } = new TempDirectory();
+ public AgentLogger Logger { get; }
+ public FakeJournalWriter Journal { get; } = new FakeJournalWriter();
+ public FakeEffectRunner Effects { get; } = new FakeEffectRunner();
+ public FakeSnapshotPersistence Snapshot { get; } = new FakeSnapshotPersistence();
+ public FakeQuarantineSink Quarantine { get; } = new FakeQuarantineSink();
+ public DecisionEngine Engine { get; } = new DecisionEngine();
+ public DecisionState InitialState { get; } = DecisionState.CreateInitial("S1", "T1");
+
+ public Rig()
+ {
+ Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
+ }
+
+ public DecisionStepProcessor Build(int threshold = DecisionStepProcessor.DefaultQuarantineThreshold) =>
+ new DecisionStepProcessor(
+ initialState: InitialState,
+ journal: Journal,
+ effectRunner: Effects,
+ snapshot: Snapshot,
+ quarantineSink: Quarantine,
+ logger: Logger,
+ quarantineThreshold: threshold);
+
+ /// Builds a real DecisionStep by Reducing SessionStarted against the current state.
+ public (DecisionStep step, DecisionSignal signal) ReduceSessionStarted(long ordinal = 0)
+ {
+ var signal = TestSignals.Raw(ordinal, DecisionSignalKind.SessionStarted, At);
+ var step = Engine.Reduce(InitialState, signal);
+ return (step, signal);
+ }
+
+ public void Dispose() => Tmp.Dispose();
+ }
+
+ // ========================================================================= Ctor
+
+ [Fact]
+ public void Ctor_rejects_null_dependencies()
+ {
+ using var rig = new Rig();
+ Assert.Throws(() =>
+ new DecisionStepProcessor(null!, rig.Journal, rig.Effects, rig.Snapshot, rig.Quarantine, rig.Logger));
+ Assert.Throws(() =>
+ new DecisionStepProcessor(rig.InitialState, null!, rig.Effects, rig.Snapshot, rig.Quarantine, rig.Logger));
+ Assert.Throws(() =>
+ new DecisionStepProcessor(rig.InitialState, rig.Journal, null!, rig.Snapshot, rig.Quarantine, rig.Logger));
+ Assert.Throws(() =>
+ new DecisionStepProcessor(rig.InitialState, rig.Journal, rig.Effects, null!, rig.Quarantine, rig.Logger));
+ Assert.Throws(() =>
+ new DecisionStepProcessor(rig.InitialState, rig.Journal, rig.Effects, rig.Snapshot, null!, rig.Logger));
+ Assert.Throws(() =>
+ new DecisionStepProcessor(rig.InitialState, rig.Journal, rig.Effects, rig.Snapshot, rig.Quarantine, null!));
+ }
+
+ [Fact]
+ public void Ctor_rejects_non_positive_threshold()
+ {
+ using var rig = new Rig();
+ Assert.Throws(() => rig.Build(threshold: 0));
+ Assert.Throws(() => rig.Build(threshold: -1));
+ }
+
+ [Fact]
+ public void CurrentState_starts_at_initial_state()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ Assert.Same(rig.InitialState, sut.CurrentState);
+ }
+
+ // ========================================================================= Happy path
+
+ [Fact]
+ public void ApplyStep_happy_path_appends_journal_runs_effects_saves_snapshot_forwards_state()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ var (step, signal) = rig.ReduceSessionStarted();
+
+ sut.ApplyStep(step, signal);
+
+ Assert.Single(rig.Journal.Appended);
+ Assert.Same(step.Transition, rig.Journal.Appended[0]);
+ Assert.Equal(1, rig.Effects.CallCount);
+ Assert.Same(step.NewState, rig.Effects.Calls[0].State);
+ Assert.Equal(signal.OccurredAtUtc, rig.Effects.Calls[0].OccurredAtUtc);
+ Assert.Single(rig.Snapshot.Saved);
+ Assert.Same(step.NewState, rig.Snapshot.Saved[0]);
+ Assert.Same(step.NewState, sut.CurrentState);
+ Assert.Equal(0, sut.ConsecutiveJournalFailureCount);
+ Assert.Empty(rig.Quarantine.Reasons);
+ }
+
+ // ========================================================================= Journal failures
+
+ [Fact]
+ public void ApplyStep_journal_fails_once_does_not_trigger_quarantine()
+ {
+ using var rig = new Rig();
+ rig.Journal.ScriptThrow(new InvalidOperationException("disk-hiccup"));
+ var sut = rig.Build();
+ var (step, signal) = rig.ReduceSessionStarted();
+
+ Assert.Throws(() => sut.ApplyStep(step, signal));
+
+ Assert.Equal(1, sut.ConsecutiveJournalFailureCount);
+ Assert.Empty(rig.Quarantine.Reasons);
+ Assert.Equal(0, rig.Effects.CallCount); // no effects run after append fails
+ Assert.Empty(rig.Snapshot.Saved);
+ Assert.Same(rig.InitialState, sut.CurrentState); // state NOT advanced
+ }
+
+ [Fact]
+ public void ApplyStep_journal_fails_threshold_times_triggers_quarantine_once()
+ {
+ using var rig = new Rig();
+ rig.Journal.ScriptThrow(new InvalidOperationException("disk-full"), count: 5);
+ var sut = rig.Build(threshold: 3);
+ var (step, signal) = rig.ReduceSessionStarted();
+
+ // Failures 1 + 2 must not quarantine.
+ Assert.Throws(() => sut.ApplyStep(step, signal));
+ Assert.Throws(() => sut.ApplyStep(step, signal));
+ Assert.Empty(rig.Quarantine.Reasons);
+
+ // Failure 3 crosses the threshold.
+ Assert.Throws(() => sut.ApplyStep(step, signal));
+ Assert.Single(rig.Quarantine.Reasons);
+ Assert.Contains("journal append failed", rig.Quarantine.Reasons[0]);
+ Assert.Contains("3x", rig.Quarantine.Reasons[0]);
+
+ // Failure 4 must not re-trigger quarantine (fire-once).
+ Assert.Throws(() => sut.ApplyStep(step, signal));
+ Assert.Single(rig.Quarantine.Reasons);
+
+ Assert.Same(rig.InitialState, sut.CurrentState);
+ }
+
+ [Fact]
+ public void ApplyStep_journal_recovers_after_transient_failure_resets_counter()
+ {
+ using var rig = new Rig();
+ rig.Journal.ScriptThrow(new InvalidOperationException("blip")).ScriptOk();
+ var sut = rig.Build();
+ var (step, signal) = rig.ReduceSessionStarted();
+
+ Assert.Throws(() => sut.ApplyStep(step, signal));
+ Assert.Equal(1, sut.ConsecutiveJournalFailureCount);
+
+ sut.ApplyStep(step, signal); // succeeds second time
+ Assert.Equal(0, sut.ConsecutiveJournalFailureCount);
+ Assert.Same(step.NewState, sut.CurrentState);
+ }
+
+ // ========================================================================= Snapshot best-effort
+
+ [Fact]
+ public void ApplyStep_snapshot_fails_does_not_fail_step()
+ {
+ using var rig = new Rig();
+ rig.Snapshot.ScriptThrow(new InvalidOperationException("snapshot-disk-full"));
+ var sut = rig.Build();
+ var (step, signal) = rig.ReduceSessionStarted();
+
+ sut.ApplyStep(step, signal); // does NOT throw
+
+ Assert.Single(rig.Journal.Appended);
+ Assert.Equal(1, rig.Effects.CallCount);
+ Assert.Same(step.NewState, sut.CurrentState); // state still advanced
+ Assert.Empty(rig.Quarantine.Reasons);
+ }
+
+ // ========================================================================= EffectRunner behavior
+
+ [Fact]
+ public void ApplyStep_effect_runner_SessionMustAbort_is_logged_but_step_committed()
+ {
+ using var rig = new Rig();
+ var abortResult = new EffectRunResult(
+ sessionMustAbort: true,
+ abortReason: "timer_infrastructure_failure: timer-broken",
+ failures: Array.Empty(),
+ classifierInvocations: 0,
+ classifierSkippedByAntiLoop: 0);
+ rig.Effects.ScriptResult(abortResult);
+
+ var sut = rig.Build();
+ var (step, signal) = rig.ReduceSessionStarted();
+
+ sut.ApplyStep(step, signal); // state still advances
+
+ Assert.Single(rig.Journal.Appended);
+ Assert.Same(step.NewState, sut.CurrentState);
+ Assert.Empty(rig.Quarantine.Reasons); // effect-failures do NOT quarantine
+ }
+
+ [Fact]
+ public void ApplyStep_effect_runner_unexpected_throw_logged_but_step_committed()
+ {
+ using var rig = new Rig();
+ rig.Effects.ScriptThrow(new InvalidOperationException("unexpected"));
+ var sut = rig.Build();
+ var (step, signal) = rig.ReduceSessionStarted();
+
+ sut.ApplyStep(step, signal); // swallowed
+
+ Assert.Single(rig.Journal.Appended);
+ Assert.Same(step.NewState, sut.CurrentState);
+ }
+
+ // ========================================================================= Argument validation
+
+ [Fact]
+ public void ApplyStep_rejects_null_step_or_signal()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ var (step, signal) = rig.ReduceSessionStarted();
+
+ Assert.Throws(() => sut.ApplyStep(null!, signal));
+ Assert.Throws(() => sut.ApplyStep(step, null!));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorUploadPathTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorUploadPathTests.cs
new file mode 100644
index 00000000..8d2a41da
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorUploadPathTests.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events;
+using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ ///
+ /// Codex Finding 1 regression gate for : verifies that
+ /// every successfully-journaled is also projected onto
+ /// the telemetry transport for backend upload, and that a throwing emitter does NOT
+ /// abort the step.
+ ///
+ public sealed class DecisionStepProcessorUploadPathTests
+ {
+ private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
+ private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+
+ private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+
+ private sealed class Rig : IDisposable
+ {
+ public TempDirectory Tmp { get; } = new TempDirectory();
+ public AgentLogger Logger { get; }
+ public FakeJournalWriter Journal { get; } = new FakeJournalWriter();
+ public FakeEffectRunner Effects { get; } = new FakeEffectRunner();
+ public FakeSnapshotPersistence Snapshot { get; } = new FakeSnapshotPersistence();
+ public FakeQuarantineSink Quarantine { get; } = new FakeQuarantineSink();
+ public DecisionEngine Engine { get; } = new DecisionEngine();
+ public DecisionState InitialState { get; } = DecisionState.CreateInitial(SessionId, TenantId);
+ public FakeTelemetryTransport Transport { get; } = new FakeTelemetryTransport();
+
+ public Rig() { Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); }
+
+ public DecisionStepProcessor Build(TelemetryTransitionEmitter? emitter)
+ => new DecisionStepProcessor(
+ initialState: InitialState,
+ journal: Journal,
+ effectRunner: Effects,
+ snapshot: Snapshot,
+ quarantineSink: Quarantine,
+ logger: Logger,
+ transitionEmitter: emitter);
+
+ public (DecisionStep step, DecisionSignal signal) ReduceSessionStarted(long ordinal = 0)
+ {
+ var signal = TestSignals.Raw(ordinal, DecisionSignalKind.SessionStarted, At);
+ var step = Engine.Reduce(InitialState, signal);
+ return (step, signal);
+ }
+
+ public void Dispose() => Tmp.Dispose();
+ }
+
+ [Fact]
+ public void ApplyStep_emits_transition_to_transport_after_Journal_append()
+ {
+ using var rig = new Rig();
+ var emitter = new TelemetryTransitionEmitter(rig.Transport, SessionId, TenantId);
+ var processor = rig.Build(emitter);
+
+ var (step, signal) = rig.ReduceSessionStarted();
+ processor.ApplyStep(step, signal);
+
+ Assert.Single(rig.Journal.Appended);
+ Assert.Equal(1, rig.Transport.EnqueueCount);
+ Assert.Equal(TelemetryItemKind.DecisionTransition, rig.Transport.Enqueued[0].Kind);
+ Assert.Equal($"{TenantId}_{SessionId}", rig.Transport.Enqueued[0].PartitionKey);
+ }
+
+ [Fact]
+ public void ApplyStep_without_emitter_keeps_pre_M5_behaviour_unchanged()
+ {
+ using var rig = new Rig();
+ var processor = rig.Build(emitter: null);
+
+ var (step, signal) = rig.ReduceSessionStarted();
+ processor.ApplyStep(step, signal);
+
+ Assert.Single(rig.Journal.Appended);
+ Assert.Equal(0, rig.Transport.EnqueueCount);
+ }
+
+ [Fact]
+ public void Journal_failure_prevents_emit_so_no_ghost_rows_leak_to_backend()
+ {
+ // Journal is authoritative. If journal.Append throws, the transition NEVER happened
+ // locally — and must NEVER be uploaded. Otherwise the backend would see phantom
+ // transitions that can't be reconciled.
+ using var rig = new Rig();
+ rig.Journal.ScriptThrow(new InvalidOperationException("disk full (test)"));
+ var emitter = new TelemetryTransitionEmitter(rig.Transport, SessionId, TenantId);
+ var processor = rig.Build(emitter);
+
+ var (step, signal) = rig.ReduceSessionStarted();
+
+ Assert.Throws(() => processor.ApplyStep(step, signal));
+ Assert.Equal(0, rig.Transport.EnqueueCount);
+ }
+
+ [Fact]
+ public void Transport_throw_is_swallowed_and_effects_still_run()
+ {
+ // Transport failure must NOT break the local post-journal pipeline: snapshot +
+ // effects + CurrentState update all happen regardless.
+ using var rig = new Rig();
+ var throwingTransport = new ThrowingTelemetryTransport();
+ var emitter = new TelemetryTransitionEmitter(throwingTransport, SessionId, TenantId);
+ var processor = rig.Build(emitter);
+
+ var (step, signal) = rig.ReduceSessionStarted();
+ processor.ApplyStep(step, signal);
+
+ Assert.Equal(1, throwingTransport.EnqueueAttempts);
+ Assert.Single(rig.Journal.Appended);
+ Assert.Equal(1, rig.Effects.CallCount);
+ Assert.Equal(step.NewState, processor.CurrentState);
+ }
+
+ private sealed class ThrowingTelemetryTransport : ITelemetryTransport
+ {
+ public int EnqueueAttempts;
+
+ public long LastUploadedItemId => -1;
+
+ public TelemetryItem Enqueue(TelemetryItemDraft draft)
+ {
+ Interlocked.Increment(ref EnqueueAttempts);
+ throw new InvalidOperationException("spool overflow (test)");
+ }
+
+ public Task DrainAllAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(DrainResult.Empty());
+
+ public void Dispose() { }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EffectRunnerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EffectRunnerTests.cs
new file mode 100644
index 00000000..cd7cea71
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EffectRunnerTests.cs
@@ -0,0 +1,411 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ public sealed class EffectRunnerTests
+ {
+ private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+
+ private static DecisionState InitialState() =>
+ DecisionState.CreateInitial("S1", "T1");
+
+ private static ActiveDeadline Deadline(string name, DateTime dueAt) =>
+ new ActiveDeadline(name, dueAt, DecisionSignalKind.DeadlineFired);
+
+ private sealed class Rig
+ {
+ public FakeDeadlineScheduler Scheduler { get; } = new FakeDeadlineScheduler();
+ public FakeSnapshotPersistence Snapshot { get; } = new FakeSnapshotPersistence();
+ public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
+ public FakeEventTimelineEmitter Emitter { get; } = new FakeEventTimelineEmitter();
+ public Dictionary Classifiers { get; } = new Dictionary();
+ public VirtualClock Clock { get; } = new VirtualClock(At);
+
+ public IEffectRunner Build() =>
+ new EffectRunner(
+ scheduler: Scheduler,
+ classifiers: new ClassifierRegistry(Classifiers.Values),
+ ingress: Ingress,
+ emitter: Emitter,
+ snapshot: Snapshot,
+ clock: Clock);
+ }
+
+ // ----- ScheduleDeadline / CancelDeadline (critical class) -----
+
+ [Fact]
+ public async Task ScheduleDeadline_forwards_to_scheduler()
+ {
+ var rig = new Rig();
+ var sut = rig.Build();
+ var d = Deadline("hello_safety", At.AddMinutes(5));
+ var effect = new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: d);
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.True(result.Success);
+ Assert.Single(rig.Scheduler.Scheduled);
+ Assert.Equal("hello_safety", rig.Scheduler.Scheduled[0].Name);
+ }
+
+ [Fact]
+ public async Task CancelDeadline_forwards_to_scheduler()
+ {
+ var rig = new Rig();
+ var sut = rig.Build();
+ var effect = new DecisionEffect(DecisionEffectKind.CancelDeadline, cancelDeadlineName: "hello_safety");
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.True(result.Success);
+ Assert.Single(rig.Scheduler.Cancelled);
+ Assert.Equal("hello_safety", rig.Scheduler.Cancelled[0]);
+ }
+
+ [Fact]
+ public async Task ScheduleDeadline_exception_aborts_session_with_timer_infrastructure_failure()
+ {
+ var rig = new Rig();
+ rig.Scheduler.ThrowOnSchedule = new InvalidOperationException("timer-broken");
+ var sut = rig.Build();
+ var effect = new DecisionEffect(
+ DecisionEffectKind.ScheduleDeadline,
+ deadline: Deadline("x", At.AddMinutes(1)));
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.True(result.SessionMustAbort);
+ Assert.Contains("timer_infrastructure_failure", result.AbortReason);
+ Assert.Single(result.Failures);
+ Assert.Equal(DecisionEffectKind.ScheduleDeadline, result.Failures[0].EffectKind);
+ Assert.False(result.Failures[0].IsTransient);
+ }
+
+ [Fact]
+ public async Task ScheduleDeadline_abort_stops_processing_subsequent_effects()
+ {
+ var rig = new Rig();
+ rig.Scheduler.ThrowOnSchedule = new InvalidOperationException("timer-broken");
+ var sut = rig.Build();
+
+ var effects = new[]
+ {
+ new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: Deadline("x", At.AddMinutes(1))),
+ new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry, parameters: new Dictionary { ["eventType"] = "won't-fire" }),
+ new DecisionEffect(DecisionEffectKind.PersistSnapshot),
+ };
+
+ var result = await sut.RunAsync(effects, InitialState(), At);
+
+ Assert.True(result.SessionMustAbort);
+ Assert.Equal(0, rig.Emitter.CallCount); // dispatching stopped
+ Assert.Empty(rig.Snapshot.Saved);
+ }
+
+ [Fact]
+ public async Task ScheduleDeadline_missing_Deadline_payload_aborts()
+ {
+ var rig = new Rig();
+ var sut = rig.Build();
+ var effect = new DecisionEffect(DecisionEffectKind.ScheduleDeadline);
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.True(result.SessionMustAbort);
+ }
+
+ // ----- EmitEventTimelineEntry (transient class) -----
+
+ [Fact]
+ public async Task EmitEventTimelineEntry_forwards_parameters_and_state()
+ {
+ var rig = new Rig();
+ var sut = rig.Build();
+ var parameters = new Dictionary { ["eventType"] = "enrollment_complete" };
+ var effect = new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry, parameters: parameters);
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.True(result.Success);
+ Assert.Equal(1, rig.Emitter.CallCount);
+ Assert.Equal("enrollment_complete", rig.Emitter.Calls[0].Parameters!["eventType"]);
+ Assert.Equal("S1", rig.Emitter.Calls[0].State.SessionId);
+ }
+
+ [Fact]
+ public async Task EmitEventTimelineEntry_retries_on_transient_failure_up_to_3_times()
+ {
+ var rig = new Rig();
+ rig.Emitter.ScriptThrow(new InvalidOperationException("hiccup"), count: 2).ScriptOk();
+ var sut = rig.Build();
+ var effect = new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry);
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.True(result.Success);
+ Assert.Equal(3, rig.Emitter.CallCount); // 2 fails + 1 success
+ }
+
+ [Fact]
+ public async Task EmitEventTimelineEntry_transient_exhaust_records_failure_without_aborting()
+ {
+ // Plan §2.7b: transient exhaust → Warning-Log, State unverändert, weiter im Flow.
+ var rig = new Rig();
+ rig.Emitter.ScriptThrow(new InvalidOperationException("persistent-hiccup"), count: 4);
+ var sut = rig.Build();
+ var effect = new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry);
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.False(result.SessionMustAbort);
+ Assert.Single(result.Failures);
+ Assert.True(result.Failures[0].IsTransient);
+ Assert.True(result.Failures[0].ExhaustedRetries);
+ Assert.Equal(4, rig.Emitter.CallCount); // 1 initial + 3 retries
+ }
+
+ [Fact]
+ public async Task EmitEventTimelineEntry_failure_does_not_block_subsequent_effects()
+ {
+ var rig = new Rig();
+ rig.Emitter.ScriptThrow(new InvalidOperationException("bad"), count: 4);
+ var sut = rig.Build();
+
+ var effects = new[]
+ {
+ new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry),
+ new DecisionEffect(DecisionEffectKind.PersistSnapshot),
+ };
+
+ var result = await sut.RunAsync(effects, InitialState(), At);
+
+ Assert.False(result.SessionMustAbort);
+ Assert.Single(result.Failures);
+ Assert.Single(rig.Snapshot.Saved);
+ }
+
+ // ----- PersistSnapshot (transient class) -----
+
+ [Fact]
+ public async Task PersistSnapshot_saves_current_state()
+ {
+ var rig = new Rig();
+ var sut = rig.Build();
+ var effect = new DecisionEffect(DecisionEffectKind.PersistSnapshot);
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.True(result.Success);
+ Assert.Single(rig.Snapshot.Saved);
+ Assert.Equal("S1", rig.Snapshot.Saved[0].SessionId);
+ }
+
+ [Fact]
+ public async Task PersistSnapshot_retries_on_transient_failure()
+ {
+ var rig = new Rig();
+ rig.Snapshot.ScriptThrow(new IOException_("disk-full"), count: 1).ScriptOk();
+ var sut = rig.Build();
+ var effect = new DecisionEffect(DecisionEffectKind.PersistSnapshot);
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.True(result.Success);
+ Assert.Equal(2, rig.Snapshot.SaveCallCount);
+ }
+
+ // ----- RunClassifier (optional class) -----
+
+ [Fact]
+ public async Task RunClassifier_happy_invokes_classifier_and_posts_verdict_signal()
+ {
+ var rig = new Rig();
+ const string Id = WhiteGloveSealingClassifier.ClassifierId;
+ const string Hash = "hash-abc123";
+ rig.Classifiers[Id] = new FakeClassifier(
+ Id,
+ _ => FakeClassifier.Verdict(Id, HypothesisLevel.Strong, Hash, score: 85));
+
+ var sut = rig.Build();
+ var effect = new DecisionEffect(
+ DecisionEffectKind.RunClassifier,
+ classifierId: Id,
+ classifierSnapshot: new HashSnapshot(Hash));
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.True(result.Success);
+ Assert.Equal(1, result.ClassifierInvocations);
+ Assert.Equal(0, result.ClassifierSkippedByAntiLoop);
+ Assert.Single(rig.Ingress.Posted);
+ Assert.Equal(DecisionSignalKind.ClassifierVerdictIssued, rig.Ingress.Posted[0].Kind);
+ Assert.Equal(At, rig.Ingress.Posted[0].OccurredAtUtc);
+ var payload = rig.Ingress.Posted[0].Payload!;
+ Assert.Equal("Strong", payload["level"]);
+ Assert.Equal(Hash, payload["inputHash"]);
+ Assert.Equal(EvidenceKind.Synthetic, rig.Ingress.Posted[0].Evidence.Kind);
+ }
+
+ [Fact]
+ public async Task RunClassifier_antiloop_skips_when_snapshot_hash_matches_prior_verdict()
+ {
+ // Seed state with a hypothesis whose LastClassifierVerdictId equals the snapshot hash.
+ const string Id = WhiteGloveSealingClassifier.ClassifierId;
+ const string Hash = "prior-hash";
+
+ var state = InitialState()
+ .ToBuilder()
+ .Apply(b => b.WhiteGloveSealing = new Hypothesis(
+ level: HypothesisLevel.Strong,
+ reason: "seed",
+ score: 80,
+ lastUpdatedUtc: At,
+ lastClassifierVerdictId: Hash))
+ .Build();
+
+ var rig = new Rig();
+ var invoked = false;
+ rig.Classifiers[Id] = new FakeClassifier(Id, _ =>
+ {
+ invoked = true;
+ return FakeClassifier.Verdict(Id, HypothesisLevel.Strong, Hash);
+ });
+
+ var sut = rig.Build();
+ var effect = new DecisionEffect(
+ DecisionEffectKind.RunClassifier,
+ classifierId: Id,
+ classifierSnapshot: new HashSnapshot(Hash));
+
+ var result = await sut.RunAsync(new[] { effect }, state, At);
+
+ Assert.True(result.Success);
+ Assert.False(invoked); // classifier not called
+ Assert.Equal(0, result.ClassifierInvocations);
+ Assert.Equal(1, result.ClassifierSkippedByAntiLoop);
+ Assert.Empty(rig.Ingress.Posted);
+ }
+
+ [Fact]
+ public async Task RunClassifier_exception_posts_Inconclusive_verdict_without_aborting()
+ {
+ var rig = new Rig();
+ const string Id = WhiteGloveSealingClassifier.ClassifierId;
+ rig.Classifiers[Id] = new FakeClassifier(Id, _ => throw new InvalidOperationException("bug"));
+
+ var sut = rig.Build();
+ var effect = new DecisionEffect(
+ DecisionEffectKind.RunClassifier,
+ classifierId: Id,
+ classifierSnapshot: new HashSnapshot("h1"));
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.False(result.SessionMustAbort);
+ Assert.Single(result.Failures);
+ Assert.Single(rig.Ingress.Posted);
+ var payload = rig.Ingress.Posted[0].Payload!;
+ Assert.Equal("Inconclusive", payload["level"]);
+ Assert.Contains("exception", payload["reason"]);
+ }
+
+ [Fact]
+ public async Task RunClassifier_unknown_id_records_failure_without_aborting()
+ {
+ var rig = new Rig();
+ var sut = rig.Build();
+ var effect = new DecisionEffect(
+ DecisionEffectKind.RunClassifier,
+ classifierId: "never-registered",
+ classifierSnapshot: new HashSnapshot("h"));
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.False(result.SessionMustAbort);
+ Assert.Single(result.Failures);
+ Assert.Contains("never-registered", result.Failures[0].ErrorReason);
+ Assert.Empty(rig.Ingress.Posted);
+ }
+
+ [Fact]
+ public async Task RunClassifier_missing_snapshot_records_failure()
+ {
+ var rig = new Rig();
+ const string Id = WhiteGloveSealingClassifier.ClassifierId;
+ rig.Classifiers[Id] = new FakeClassifier(Id, _ => FakeClassifier.Verdict(Id, HypothesisLevel.Weak, "x"));
+ var sut = rig.Build();
+ var effect = new DecisionEffect(DecisionEffectKind.RunClassifier, classifierId: Id);
+
+ var result = await sut.RunAsync(new[] { effect }, InitialState(), At);
+
+ Assert.Single(result.Failures);
+ Assert.False(result.SessionMustAbort);
+ }
+
+ // ----- Batches and edge cases -----
+
+ [Fact]
+ public async Task Multiple_effects_in_one_batch_dispatch_in_order()
+ {
+ var rig = new Rig();
+ const string Id = WhiteGloveSealingClassifier.ClassifierId;
+ rig.Classifiers[Id] = new FakeClassifier(Id, _ => FakeClassifier.Verdict(Id, HypothesisLevel.Strong, "h"));
+ var sut = rig.Build();
+
+ var effects = new[]
+ {
+ new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: Deadline("a", At.AddMinutes(1))),
+ new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry),
+ new DecisionEffect(DecisionEffectKind.RunClassifier, classifierId: Id, classifierSnapshot: new HashSnapshot("h")),
+ new DecisionEffect(DecisionEffectKind.PersistSnapshot),
+ new DecisionEffect(DecisionEffectKind.CancelDeadline, cancelDeadlineName: "other"),
+ };
+
+ var result = await sut.RunAsync(effects, InitialState(), At);
+
+ Assert.True(result.Success);
+ Assert.Single(rig.Scheduler.Scheduled);
+ Assert.Equal(1, rig.Emitter.CallCount);
+ Assert.Equal(1, result.ClassifierInvocations);
+ Assert.Single(rig.Snapshot.Saved);
+ Assert.Single(rig.Scheduler.Cancelled);
+ }
+
+ [Fact]
+ public async Task Empty_effects_list_returns_empty_success()
+ {
+ var rig = new Rig();
+ var sut = rig.Build();
+
+ var result = await sut.RunAsync(Array.Empty(), InitialState(), At);
+
+ Assert.True(result.Success);
+ Assert.Equal(0, result.ClassifierInvocations);
+ }
+ }
+
+ // Local exception type so we don't take a dep on System.IO.
+ internal sealed class IOException_ : Exception
+ {
+ public IOException_(string msg) : base(msg) { }
+ }
+
+ // Tiny builder-extension helper so tests can set builder props inline.
+ internal static class DecisionStateBuilderTestExt
+ {
+ public static DecisionStateBuilder Apply(this DecisionStateBuilder b, Action mutate)
+ {
+ mutate(b);
+ return b;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs
new file mode 100644
index 00000000..67b6eac8
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs
@@ -0,0 +1,294 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
+using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+#pragma warning disable xUnit1031
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ public sealed class EnrollmentOrchestratorEventBridgeTests
+ {
+ private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+
+ /// Test host that lets a test manually fire enrollment events.
+ private sealed class FakeCollectorHost : ICollectorHost
+ {
+ private readonly Action _sink;
+ public string Name { get; }
+ public int StartCalls { get; private set; }
+ public int StopCalls { get; private set; }
+ public int DisposeCalls { get; private set; }
+
+ public FakeCollectorHost(string name, Action sink)
+ {
+ Name = name;
+ _sink = sink;
+ }
+
+ public void Start() => StartCalls++;
+ public void Stop() => StopCalls++;
+ public void Dispose() => DisposeCalls++;
+
+ public void Emit(EnrollmentEvent evt) => _sink(evt);
+ }
+
+ private sealed class FakeComponentFactory : IComponentFactory
+ {
+ public Action? CapturedSink { get; private set; }
+ public readonly List Hosts = new List();
+ public readonly IReadOnlyList HostNames;
+
+ public FakeComponentFactory(params string[] hostNames)
+ {
+ HostNames = hostNames.Length == 0
+ ? new[] { "HelloTracker", "ShellCoreTracker", "ProvisioningStatusTracker", "StallProbeCollector", "ModernDeploymentTracker" }
+ : hostNames;
+ }
+
+ public IReadOnlyCollection? CapturedWhiteGloveSealingPatternIds { get; private set; }
+
+ public ISignalIngressSink? CapturedIngress { get; private set; }
+ public IClock? CapturedClock { get; private set; }
+
+ public IReadOnlyList CreateCollectorHosts(
+ string sessionId,
+ string tenantId,
+ AgentLogger logger,
+ Action onEnrollmentEvent,
+ IReadOnlyCollection whiteGloveSealingPatternIds,
+ ISignalIngressSink ingress,
+ IClock clock)
+ {
+ CapturedSink = onEnrollmentEvent;
+ CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds;
+ CapturedIngress = ingress;
+ CapturedClock = clock;
+ foreach (var name in HostNames)
+ {
+ Hosts.Add(new FakeCollectorHost(name, onEnrollmentEvent));
+ }
+ return Hosts;
+ }
+ }
+
+ private sealed class Rig : IDisposable
+ {
+ public TempDirectory Tmp { get; } = new TempDirectory();
+ public VirtualClock Clock { get; } = new VirtualClock(At);
+ public AgentLogger Logger { get; }
+ public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader();
+ public List Classifiers { get; } = new List();
+ public FakeComponentFactory Factory { get; }
+ public string StateDir { get; }
+ public string TransportDir { get; }
+
+ public Rig(FakeComponentFactory? factory = null)
+ {
+ Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
+ StateDir = Path.Combine(Tmp.Path, "State");
+ TransportDir = Path.Combine(Tmp.Path, "Transport");
+ Factory = factory ?? new FakeComponentFactory();
+ }
+
+ public EnrollmentOrchestrator Build() =>
+ new EnrollmentOrchestrator(
+ sessionId: "S1",
+ tenantId: "T1",
+ stateDirectory: StateDir,
+ transportDirectory: TransportDir,
+ clock: Clock,
+ logger: Logger,
+ uploader: Uploader,
+ classifiers: Classifiers,
+ componentFactory: Factory,
+ drainInterval: TimeSpan.FromDays(1),
+ terminalDrainTimeout: TimeSpan.FromSeconds(2));
+
+ public void Dispose() => Tmp.Dispose();
+ }
+
+ private static EnrollmentEvent Evt(string eventType) => new EnrollmentEvent
+ {
+ EventType = eventType,
+ Severity = EventSeverity.Info,
+ Source = "test",
+ Phase = EnrollmentPhase.Unknown,
+ Timestamp = At,
+ };
+
+ // ========================================================================= Wiring
+
+ [Fact]
+ public void Start_without_factory_does_not_throw_and_spawns_no_hosts()
+ {
+ using var tmp = new TempDirectory();
+ var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
+ var uploader = new FakeBackendTelemetryUploader();
+ var sut = new EnrollmentOrchestrator(
+ sessionId: "S1",
+ tenantId: "T1",
+ stateDirectory: Path.Combine(tmp.Path, "State"),
+ transportDirectory: Path.Combine(tmp.Path, "Transport"),
+ clock: new VirtualClock(At),
+ logger: logger,
+ uploader: uploader,
+ classifiers: new List(),
+ componentFactory: null,
+ drainInterval: TimeSpan.FromDays(1),
+ terminalDrainTimeout: TimeSpan.FromSeconds(2));
+
+ sut.Start();
+ sut.Stop(); // no exception
+ }
+
+ [Fact]
+ public void Start_with_factory_creates_all_hosts_and_captures_sink()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+
+ sut.Start();
+
+ Assert.NotNull(rig.Factory.CapturedSink);
+ Assert.Equal(5, rig.Factory.Hosts.Count);
+ Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.StartCalls));
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Emit_from_host_reaches_telemetry_transport()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ var hello = rig.Factory.Hosts.Find(h => h.Name == "HelloTracker")!;
+ hello.Emit(Evt("hello_completed"));
+
+ var spool = GetSpool(sut);
+ var pending = spool.Peek(10);
+ Assert.Single(pending);
+ Assert.Equal(TelemetryItemKind.Event, pending[0].Kind);
+ Assert.Contains("hello_completed", pending[0].PayloadJson);
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Emit_from_each_of_five_hosts_produces_five_transport_items()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ foreach (var host in rig.Factory.Hosts)
+ {
+ host.Emit(Evt($"event_from_{host.Name}"));
+ }
+
+ var pending = GetSpool(sut).Peek(10);
+ Assert.Equal(5, pending.Count);
+ foreach (var host in rig.Factory.Hosts)
+ {
+ Assert.Contains(pending, p => p.PayloadJson.Contains($"event_from_{host.Name}"));
+ }
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Modern_deployment_host_emits_event_bridge_only_no_decision_signal()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ var modern = rig.Factory.Hosts.Find(h => h.Name == "ModernDeploymentTracker")!;
+ modern.Emit(Evt("modern_deployment_log"));
+
+ // Event reached transport (bridge works).
+ var pending = GetSpool(sut).Peek(10);
+ Assert.Single(pending);
+ Assert.Contains("modern_deployment_log", pending[0].PayloadJson);
+
+ // No decision-signal was posted — signal-log is empty (no SignalAdapter wraps
+ // ModernDeploymentTracker by design, Plan §4.x M4.3-Erkenntnis).
+ var signalLog = GetSignalLog(sut);
+ Assert.Empty(signalLog.ReadAll());
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Emitter_exception_does_not_propagate_to_host_thread()
+ {
+ // Simulate: TelemetryEventEmitter throws (e.g. transport disposed mid-emit).
+ // Build a shared sink that throws on demand; wrap into a host that uses it directly.
+ // Easier path: dispose the transport post-Start so Enqueue throws, then fire an event.
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ // Force a transport-level exception by disposing it mid-run.
+ GetTransport(sut).Dispose();
+
+ var hello = rig.Factory.Hosts.Find(h => h.Name == "HelloTracker")!;
+
+ // Must not throw — orchestrator wraps the emitter call in try/catch.
+ var ex = Record.Exception(() => hello.Emit(Evt("hello_after_dispose")));
+ Assert.Null(ex);
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Stop_stops_and_disposes_all_hosts_in_creation_order()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ sut.Stop();
+
+ Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.StopCalls));
+ Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.DisposeCalls));
+ }
+
+ // ========================================================================= Helpers
+
+ private static TelemetrySpool GetSpool(EnrollmentOrchestrator sut)
+ {
+ var field = typeof(EnrollmentOrchestrator).GetField(
+ "_spool",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ return (TelemetrySpool)field!.GetValue(sut)!;
+ }
+
+ private static TelemetryUploadOrchestrator GetTransport(EnrollmentOrchestrator sut)
+ {
+ var field = typeof(EnrollmentOrchestrator).GetField(
+ "_transport",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ return (TelemetryUploadOrchestrator)field!.GetValue(sut)!;
+ }
+
+ private static AutopilotMonitor.Agent.V2.Core.Persistence.ISignalLogWriter GetSignalLog(EnrollmentOrchestrator sut)
+ {
+ var field = typeof(EnrollmentOrchestrator).GetField(
+ "_signalLog",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ return (AutopilotMonitor.Agent.V2.Core.Persistence.ISignalLogWriter)field!.GetValue(sut)!;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs
new file mode 100644
index 00000000..e7bd180d
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ ///
+ /// M4.6.α — max-lifetime watchdog. When the configured lifetime elapses without a
+ /// decision-terminal stage, the orchestrator raises
+ /// with reason and outcome
+ /// .
+ ///
+ [Collection("SerialThreading")]
+ public sealed class EnrollmentOrchestratorMaxLifetimeTests
+ {
+ private static DateTime At => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
+
+ private static EnrollmentOrchestrator Build(
+ TempDirectory tmp,
+ TimeSpan? agentMaxLifetime)
+ {
+ var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
+ return new EnrollmentOrchestrator(
+ sessionId: "S1",
+ tenantId: "T1",
+ stateDirectory: Path.Combine(tmp.Path, "State"),
+ transportDirectory: Path.Combine(tmp.Path, "Transport"),
+ clock: new VirtualClock(At),
+ logger: logger,
+ uploader: new FakeBackendTelemetryUploader(),
+ classifiers: new List(),
+ componentFactory: null,
+ drainInterval: TimeSpan.FromDays(1),
+ terminalDrainTimeout: TimeSpan.FromSeconds(2),
+ agentMaxLifetime: agentMaxLifetime);
+ }
+
+ [Fact]
+ public void Terminated_fires_once_when_max_lifetime_elapses()
+ {
+ using var tmp = new TempDirectory();
+ var sut = Build(tmp, agentMaxLifetime: TimeSpan.FromMilliseconds(200));
+
+ using var fired = new ManualResetEventSlim(false);
+ EnrollmentTerminatedEventArgs? captured = null;
+ var fireCount = 0;
+ sut.Terminated += (_, e) =>
+ {
+ Interlocked.Increment(ref fireCount);
+ captured = e;
+ fired.Set();
+ };
+
+ sut.Start();
+ Assert.True(fired.Wait(5000), "Terminated event did not fire within 5s.");
+
+ Assert.NotNull(captured);
+ Assert.Equal(EnrollmentTerminationReason.MaxLifetimeExceeded, captured!.Reason);
+ Assert.Equal(EnrollmentTerminationOutcome.TimedOut, captured.Outcome);
+ Assert.NotNull(captured.StageName);
+ Assert.NotNull(captured.Details);
+
+ // Even if the timer fired multiple times (it shouldn't — it's one-shot), we only
+ // raise the event once thanks to the Interlocked latch.
+ Thread.Sleep(300);
+ Assert.Equal(1, fireCount);
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Terminated_not_fired_when_max_lifetime_is_null()
+ {
+ using var tmp = new TempDirectory();
+ var sut = Build(tmp, agentMaxLifetime: null);
+
+ var fireCount = 0;
+ sut.Terminated += (_, _) => Interlocked.Increment(ref fireCount);
+
+ sut.Start();
+ Thread.Sleep(200);
+ sut.Stop();
+
+ Assert.Equal(0, fireCount);
+ }
+
+ [Fact]
+ public void Ctor_rejects_non_positive_max_lifetime()
+ {
+ using var tmp = new TempDirectory();
+ Assert.Throws(() => Build(tmp, agentMaxLifetime: TimeSpan.Zero));
+ Assert.Throws(() => Build(tmp, agentMaxLifetime: TimeSpan.FromMilliseconds(-1)));
+ }
+
+ [Fact]
+ public void Stop_before_lifetime_elapses_silences_the_watchdog()
+ {
+ using var tmp = new TempDirectory();
+ var sut = Build(tmp, agentMaxLifetime: TimeSpan.FromSeconds(5));
+
+ var fireCount = 0;
+ sut.Terminated += (_, _) => Interlocked.Increment(ref fireCount);
+
+ sut.Start();
+ Thread.Sleep(50);
+ sut.Stop();
+
+ // Give any racing timer callback a chance — it must not fire after Stop().
+ Thread.Sleep(200);
+ Assert.Equal(0, fireCount);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs
new file mode 100644
index 00000000..7985fb3c
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs
@@ -0,0 +1,330 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Persistence;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Serialization;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+#pragma warning disable xUnit1031 // Task.Wait + SpinWait for deterministic thread coordination
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ public sealed class EnrollmentOrchestratorTests
+ {
+ private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+
+ private sealed class Rig : IDisposable
+ {
+ public TempDirectory Tmp { get; } = new TempDirectory();
+ public VirtualClock Clock { get; } = new VirtualClock(At);
+ public AgentLogger Logger { get; }
+ public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader();
+ public List Classifiers { get; } = new List();
+ public string StateDir { get; }
+ public string TransportDir { get; }
+
+ public Rig()
+ {
+ Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
+ StateDir = Path.Combine(Tmp.Path, "State");
+ TransportDir = Path.Combine(Tmp.Path, "Transport");
+ }
+
+ public EnrollmentOrchestrator Build(TimeSpan? drainInterval = null) =>
+ new EnrollmentOrchestrator(
+ sessionId: "S1",
+ tenantId: "T1",
+ stateDirectory: StateDir,
+ transportDirectory: TransportDir,
+ clock: Clock,
+ logger: Logger,
+ uploader: Uploader,
+ classifiers: Classifiers,
+ // Default drain-interval = 1 day → periodic drain won't fire during short tests.
+ drainInterval: drainInterval ?? TimeSpan.FromDays(1),
+ terminalDrainTimeout: TimeSpan.FromSeconds(2));
+
+ public void Dispose() => Tmp.Dispose();
+ }
+
+ // ========================================================================= Ctor
+
+ [Fact]
+ public void Ctor_rejects_empty_required_strings()
+ {
+ using var rig = new Rig();
+ Assert.Throws(() => new EnrollmentOrchestrator(
+ "", "T1", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers));
+ Assert.Throws(() => new EnrollmentOrchestrator(
+ "S1", "", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers));
+ Assert.Throws(() => new EnrollmentOrchestrator(
+ "S1", "T1", "", rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers));
+ Assert.Throws(() => new EnrollmentOrchestrator(
+ "S1", "T1", rig.StateDir, "", rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers));
+ }
+
+ [Fact]
+ public void Ctor_rejects_null_dependencies()
+ {
+ using var rig = new Rig();
+ Assert.Throws(() => new EnrollmentOrchestrator(
+ "S1", "T1", rig.StateDir, rig.TransportDir, null!, rig.Logger, rig.Uploader, rig.Classifiers));
+ Assert.Throws(() => new EnrollmentOrchestrator(
+ "S1", "T1", rig.StateDir, rig.TransportDir, rig.Clock, null!, rig.Uploader, rig.Classifiers));
+ Assert.Throws(() => new EnrollmentOrchestrator(
+ "S1", "T1", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, null!, rig.Classifiers));
+ Assert.Throws(() => new EnrollmentOrchestrator(
+ "S1", "T1", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, null!));
+ }
+
+ [Fact]
+ public void Observability_before_start_throws()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ Assert.Throws(() => sut.CurrentState);
+ Assert.Throws(() => sut.IngressSink);
+ Assert.Throws(() => sut.EventEmitter);
+ }
+
+ // ========================================================================= Start
+
+ [Fact]
+ public void Start_instantiates_pipeline_no_exception()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+
+ sut.Start();
+
+ Assert.NotNull(sut.CurrentState);
+ Assert.Equal("S1", sut.CurrentState.SessionId);
+ Assert.Equal("T1", sut.CurrentState.TenantId);
+ Assert.NotNull(sut.IngressSink);
+ Assert.NotNull(sut.EventEmitter);
+ Assert.False(sut.IsQuarantineRequested);
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Start_creates_state_and_transport_directories()
+ {
+ using var rig = new Rig();
+ Assert.False(Directory.Exists(rig.StateDir));
+ Assert.False(Directory.Exists(rig.TransportDir));
+
+ var sut = rig.Build();
+ sut.Start();
+
+ Assert.True(Directory.Exists(rig.StateDir));
+ Assert.True(Directory.Exists(rig.TransportDir));
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Start_creates_initial_state_when_no_snapshot()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ Assert.Equal(SessionStage.SessionStarted, sut.CurrentState.Stage);
+ Assert.Equal(0, sut.CurrentState.StepIndex);
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Start_loads_recovered_snapshot_when_file_exists()
+ {
+ using var rig = new Rig();
+ Directory.CreateDirectory(rig.StateDir);
+ // Seed a snapshot with a non-initial stage so recovery is detectable.
+ var persistedState = DecisionState.CreateInitial("S1", "T1")
+ .ToBuilder()
+ .WithStage(SessionStage.EspDeviceSetup)
+ .WithStepIndex(7)
+ .Build();
+ var snapshotPath = Path.Combine(rig.StateDir, "snapshot.json");
+ var seed = new SnapshotPersistence(snapshotPath);
+ seed.Save(persistedState);
+
+ var sut = rig.Build();
+ sut.Start();
+
+ Assert.Equal(SessionStage.EspDeviceSetup, sut.CurrentState.Stage);
+ Assert.Equal(7, sut.CurrentState.StepIndex);
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Start_twice_throws()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ Assert.Throws(() => sut.Start());
+
+ sut.Stop();
+ }
+
+ // ========================================================================= Stop
+
+ [Fact]
+ public void Stop_before_start_is_noop()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+
+ sut.Stop(); // does not throw
+ }
+
+ [Fact]
+ public void Stop_is_idempotent()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ sut.Stop();
+ sut.Stop(); // no-op on second call
+ }
+
+ [Fact]
+ public void Stop_flushes_final_snapshot_with_current_state()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ // Drive a state change — post a SessionStarted synthetic signal so the reducer
+ // advances StepIndex. Use the IngressSink to mirror the prod path.
+ sut.IngressSink.Post(
+ kind: DecisionSignalKind.SessionStarted,
+ occurredAtUtc: At,
+ sourceOrigin: "Test",
+ evidence: new Evidence(EvidenceKind.Synthetic, "test", "test"));
+
+ // Wait until the worker applied it.
+ Assert.True(SpinWait.SpinUntil(() => sut.CurrentState.StepIndex > 0, 2000));
+ var stepIndexBeforeStop = sut.CurrentState.StepIndex;
+
+ sut.Stop();
+
+ // Snapshot file must exist + contain the final state.
+ var snapshotPath = Path.Combine(rig.StateDir, "snapshot.json");
+ Assert.True(File.Exists(snapshotPath));
+ var reloaded = new SnapshotPersistence(snapshotPath).Load();
+ Assert.NotNull(reloaded);
+ Assert.Equal(stepIndexBeforeStop, reloaded!.StepIndex);
+ }
+
+ // ========================================================================= Dispose
+
+ [Fact]
+ public void Dispose_stops_orchestrator()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ sut.Dispose();
+
+ // Second Dispose is safe; Start after Dispose throws ObjectDisposedException.
+ sut.Dispose();
+ Assert.Throws(() => sut.Start());
+ }
+
+ // ========================================================================= Deadline bridge
+
+ [Fact]
+ public void Deadline_fired_posts_DeadlineFired_signal_to_ingress()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ // Schedule a past-due deadline → DeadlineScheduler fires via ThreadPool immediately.
+ var scheduler = GetScheduler(sut);
+ var pastDue = new ActiveDeadline(
+ name: "hello_safety",
+ dueAtUtc: At.AddMinutes(-1),
+ firesSignalKind: DecisionSignalKind.DeadlineFired);
+ scheduler.Schedule(pastDue);
+
+ // Wait for the signal-log to record the synthetic DeadlineFired signal. We read
+ // via the SignalLogWriter's own thread-safe ReadAll() to avoid racing the worker's
+ // file-append lock (File.ReadAllLines opens without FileShare.ReadWrite on net48).
+ var signalLog = GetSignalLog(sut);
+ Assert.True(SpinWait.SpinUntil(
+ () => ContainsDeadlineFired(signalLog),
+ 3000));
+
+ sut.Stop();
+ }
+
+ // ========================================================================= Quarantine
+
+ [Fact]
+ public void TriggerQuarantine_sets_flag_and_reason()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build();
+ sut.Start();
+
+ Assert.False(sut.IsQuarantineRequested);
+
+ sut.TriggerQuarantine("test-reason");
+
+ Assert.True(sut.IsQuarantineRequested);
+ Assert.Equal("test-reason", sut.QuarantineReason);
+
+ sut.Stop();
+ }
+
+ // ========================================================================= Helpers
+
+ private static bool ContainsDeadlineFired(ISignalLogWriter signalLog)
+ {
+ foreach (var sig in signalLog.ReadAll())
+ {
+ if (sig.Kind == DecisionSignalKind.DeadlineFired) return true;
+ }
+ return false;
+ }
+
+ private static ISignalLogWriter GetSignalLog(EnrollmentOrchestrator sut)
+ {
+ var field = typeof(EnrollmentOrchestrator).GetField(
+ "_signalLog",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ return (ISignalLogWriter)field!.GetValue(sut)!;
+ }
+
+ ///
+ /// The orchestrator owns its as a private field;
+ /// tests need it to script past-due deadlines. Reflection access is acceptable for
+ /// a single integration-ish test; production code doesn't expose it.
+ ///
+ private static DeadlineScheduler GetScheduler(EnrollmentOrchestrator sut)
+ {
+ var field = typeof(EnrollmentOrchestrator).GetField(
+ "_scheduler",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ return (DeadlineScheduler)field!.GetValue(sut)!;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs
new file mode 100644
index 00000000..5663a9b2
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ public sealed class EnrollmentOrchestratorWhiteGlovePatternTests
+ {
+ private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+
+ private sealed class CapturingFactory : IComponentFactory
+ {
+ public IReadOnlyCollection? CapturedPatternIds { get; private set; }
+
+ public IReadOnlyList CreateCollectorHosts(
+ string sessionId,
+ string tenantId,
+ AgentLogger logger,
+ Action onEnrollmentEvent,
+ IReadOnlyCollection whiteGloveSealingPatternIds,
+ ISignalIngressSink ingress,
+ IClock clock)
+ {
+ CapturedPatternIds = whiteGloveSealingPatternIds;
+ return Array.Empty();
+ }
+ }
+
+ private EnrollmentOrchestrator Build(
+ TempDirectory tmp,
+ IComponentFactory? factory = null,
+ IReadOnlyCollection? whiteGloveSealingPatternIds = null)
+ {
+ var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
+ return new EnrollmentOrchestrator(
+ sessionId: "S1",
+ tenantId: "T1",
+ stateDirectory: Path.Combine(tmp.Path, "State"),
+ transportDirectory: Path.Combine(tmp.Path, "Transport"),
+ clock: new VirtualClock(At),
+ logger: logger,
+ uploader: new FakeBackendTelemetryUploader(),
+ classifiers: new List(),
+ componentFactory: factory,
+ whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
+ drainInterval: TimeSpan.FromDays(1),
+ terminalDrainTimeout: TimeSpan.FromSeconds(2));
+ }
+
+ [Fact]
+ public void Null_pattern_ids_reach_factory_as_empty_collection()
+ {
+ using var tmp = new TempDirectory();
+ var factory = new CapturingFactory();
+ var sut = Build(tmp, factory, whiteGloveSealingPatternIds: null);
+
+ sut.Start();
+
+ Assert.NotNull(factory.CapturedPatternIds);
+ Assert.Empty(factory.CapturedPatternIds!);
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Configured_pattern_ids_reach_factory_verbatim()
+ {
+ using var tmp = new TempDirectory();
+ var factory = new CapturingFactory();
+ var ids = new[] { "WG_SEAL_1", "WG_SEAL_2", "WG_SEAL_3" };
+ var sut = Build(tmp, factory, whiteGloveSealingPatternIds: ids);
+
+ sut.Start();
+
+ Assert.NotNull(factory.CapturedPatternIds);
+ Assert.Equal(ids, factory.CapturedPatternIds);
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Empty_pattern_ids_collection_forwards_as_empty_not_null()
+ {
+ using var tmp = new TempDirectory();
+ var factory = new CapturingFactory();
+ var sut = Build(tmp, factory, whiteGloveSealingPatternIds: Array.Empty());
+
+ sut.Start();
+
+ Assert.NotNull(factory.CapturedPatternIds);
+ Assert.Empty(factory.CapturedPatternIds!);
+
+ sut.Stop();
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeBackPressureObserver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeBackPressureObserver.cs
new file mode 100644
index 00000000..ade36697
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeBackPressureObserver.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ internal sealed class FakeBackPressureObserver : IBackPressureObserver
+ {
+ private readonly object _lock = new object();
+ private readonly List _calls = new List();
+
+ public int CallCount
+ {
+ get { lock (_lock) return _calls.Count; }
+ }
+
+ public IReadOnlyList Calls
+ {
+ get { lock (_lock) return _calls.ToArray(); }
+ }
+
+ public Exception? ThrowOnNotify { get; set; }
+
+ public void OnBackPressure(string origin, int channelCapacity, int queueLength, TimeSpan blockDuration)
+ {
+ lock (_lock)
+ {
+ _calls.Add(new BackPressureCall(origin, channelCapacity, queueLength, blockDuration));
+ }
+ if (ThrowOnNotify != null) throw ThrowOnNotify;
+ }
+
+ internal sealed class BackPressureCall
+ {
+ public BackPressureCall(string origin, int channelCapacity, int queueLength, TimeSpan blockDuration)
+ {
+ Origin = origin;
+ ChannelCapacity = channelCapacity;
+ QueueLength = queueLength;
+ BlockDuration = blockDuration;
+ }
+
+ public string Origin { get; }
+ public int ChannelCapacity { get; }
+ public int QueueLength { get; }
+ public TimeSpan BlockDuration { get; }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeClassifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeClassifier.cs
new file mode 100644
index 00000000..1af4cb6c
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeClassifier.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ ///
+ /// Test fake — returns a canned verdict or throws on demand.
+ ///
+ internal sealed class FakeClassifier : IClassifier
+ {
+ public FakeClassifier(string id, Func