diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/UserSidResolverTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/UserSidResolverTests.cs
new file mode 100644
index 00000000..aea0ecd0
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/UserSidResolverTests.cs
@@ -0,0 +1,41 @@
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
+{
+ public sealed class UserSidResolverTests
+ {
+ [Fact]
+ public void Resolves_well_known_builtin_administrators_to_canonical_sid()
+ {
+ // BUILTIN\Administrators is a well-known local group with a deterministic SID on
+ // every Windows installation. Using it as the round-trip oracle avoids any
+ // environment-specific user-database dependency.
+ Assert.True(UserSidResolver.TryResolveSid("BUILTIN\\Administrators", out var sid));
+ Assert.Equal("S-1-5-32-544", sid);
+ }
+
+ [Fact]
+ public void Resolves_bare_user_form_for_well_known_account()
+ {
+ // SYSTEM ("NT AUTHORITY\SYSTEM") is well-known with SID S-1-5-18. The resolver
+ // must accept the qualified form on every Windows installation.
+ Assert.True(UserSidResolver.TryResolveSid("NT AUTHORITY\\SYSTEM", out var sid));
+ Assert.Equal("S-1-5-18", sid);
+ }
+
+ [Fact]
+ public void Returns_false_for_obviously_invalid_account()
+ {
+ Assert.False(UserSidResolver.TryResolveSid("CONTOSO\\definitely-not-a-real-user-7f3e2c9a", out var sid));
+ Assert.Null(sid);
+ }
+
+ [Fact]
+ public void Returns_false_for_empty_input()
+ {
+ Assert.False(UserSidResolver.TryResolveSid(string.Empty, out var sid));
+ Assert.Null(sid);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs
new file mode 100644
index 00000000..2c23887f
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Linq;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
+using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
+{
+ public sealed class RealmJoinWatcherAdapterTests
+ {
+ private static readonly DateTime Fixed = new DateTime(2026, 5, 21, 10, 0, 0, DateTimeKind.Utc);
+
+ private sealed class Fixture : IDisposable
+ {
+ public TempDirectory Tmp { get; } = new TempDirectory();
+ public AgentLogger Logger { get; }
+ public RealmJoinWatcher Watcher { get; }
+ public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
+ public VirtualClock Clock { get; } = new VirtualClock(Fixed);
+
+ public Fixture()
+ {
+ Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
+ Watcher = new RealmJoinWatcher(Logger);
+ }
+
+ public void Dispose()
+ {
+ Watcher.Dispose();
+ Tmp.Dispose();
+ }
+ }
+
+ [Fact]
+ public void TriggerDetectedFromTest_emits_RealmJoinDetected_signal_and_informational_event()
+ {
+ using var f = new Fixture();
+ using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock);
+
+ adapter.TriggerDetectedFromTest(phase: 100);
+
+ // 1) Decision signal that mutates engine state.
+ var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinDetected);
+ Assert.Equal("RealmJoinWatcher", decision.SourceOrigin);
+ Assert.Equal("100", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase]);
+
+ // 2) Dual-emit informational event so the timeline shows realmjoin_detected.
+ var info = f.Ingress.Posted.Single(p =>
+ p.Kind == DecisionSignalKind.InformationalEvent
+ && p.Payload != null
+ && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
+ && et == SharedEventTypes.RealmJoinDetected);
+ Assert.Equal("RealmJoinWatcher", info.Payload![SignalPayloadKeys.Source]);
+ Assert.Equal("true", info.Payload[SignalPayloadKeys.ImmediateUpload]);
+ Assert.Equal("100", info.Payload["deploymentPhase"]);
+ Assert.Contains("HKLM", info.Payload["registryKey"]);
+ }
+
+ [Fact]
+ public void TriggerResolvedFromTest_emits_RealmJoinResolved_signal_and_informational_event()
+ {
+ using var f = new Fixture();
+ using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock);
+
+ adapter.TriggerResolvedFromTest(phase: 110);
+
+ var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinResolved);
+ Assert.Equal("110", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase]);
+
+ var info = f.Ingress.Posted.Single(p =>
+ p.Kind == DecisionSignalKind.InformationalEvent
+ && p.Payload != null
+ && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
+ && et == SharedEventTypes.RealmJoinResolved);
+ Assert.Equal("RealmJoinWatcher", info.Payload![SignalPayloadKeys.Source]);
+ Assert.Equal("110", info.Payload["deploymentPhase"]);
+ }
+
+ [Fact]
+ public void TriggerPhaseChangedFromTest_emits_informational_only_no_decision_signal()
+ {
+ using var f = new Fixture();
+ using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock);
+
+ adapter.TriggerPhaseChangedFromTest(prev: 100, curr: 200);
+
+ // Phase change is observability-only. There is no DecisionSignalKind.RealmJoinPhaseChanged.
+ Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinDetected);
+ Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinResolved);
+
+ var info = f.Ingress.Posted.Single(p =>
+ p.Kind == DecisionSignalKind.InformationalEvent
+ && p.Payload != null
+ && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
+ && et == SharedEventTypes.RealmJoinPhaseChanged);
+ Assert.Equal("200", info.Payload!["deploymentPhase"]);
+ Assert.Equal("100", info.Payload["previousPhase"]);
+ }
+
+ [Fact]
+ public void TriggerPackageStartedFromTest_emits_signal_and_event_with_packageId_scope()
+ {
+ using var f = new Fixture();
+ using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock);
+
+ var snap = new RealmJoinPackageSnapshot(
+ packageId: "generic-vlc",
+ displayName: "VLC media player",
+ version: "3.0.21.0",
+ success: null,
+ lastExitCode: null);
+ adapter.TriggerPackageStartedFromTest(scope: RealmJoinPackageFact.ScopeMachine, snap: snap);
+
+ var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinPackageStarted);
+ Assert.Equal("generic-vlc", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.PackageId]);
+ Assert.Equal("VLC media player", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.DisplayName]);
+ Assert.Equal("3.0.21.0", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.Version]);
+ Assert.Equal("machine", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.Scope]);
+
+ var info = f.Ingress.Posted.Single(p =>
+ p.Kind == DecisionSignalKind.InformationalEvent
+ && p.Payload != null
+ && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
+ && et == SharedEventTypes.RealmJoinPackageStarted);
+ Assert.Equal("generic-vlc", info.Payload!["packageId"]);
+ Assert.Equal("machine", info.Payload["scope"]);
+ }
+
+ [Fact]
+ public void TriggerPackageCompletedFromTest_emits_signal_and_event_with_success_and_exit_code()
+ {
+ using var f = new Fixture();
+ using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock);
+
+ var snap = new RealmJoinPackageSnapshot(
+ packageId: "generic-vlc",
+ displayName: "VLC media player",
+ version: "3.0.21.0",
+ success: true,
+ lastExitCode: 0);
+ adapter.TriggerPackageCompletedFromTest(scope: RealmJoinPackageFact.ScopeMachine, snap: snap);
+
+ var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinPackageCompleted);
+ Assert.Equal("true", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.Success]);
+ Assert.Equal("0", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.LastExitCode]);
+
+ var info = f.Ingress.Posted.Single(p =>
+ p.Kind == DecisionSignalKind.InformationalEvent
+ && p.Payload != null
+ && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
+ && et == SharedEventTypes.RealmJoinPackageCompleted);
+ Assert.Equal("true", info.Payload!["success"]);
+ Assert.Equal("0", info.Payload["lastExitCode"]);
+ }
+
+ [Fact]
+ public void Overlong_display_name_is_truncated_to_256_characters()
+ {
+ using var f = new Fixture();
+ using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock);
+
+ var longName = new string('x', 1000);
+ var snap = new RealmJoinPackageSnapshot(
+ packageId: "generic-bigname",
+ displayName: longName,
+ version: null,
+ success: null,
+ lastExitCode: null);
+ adapter.TriggerPackageStartedFromTest(scope: RealmJoinPackageFact.ScopeUser, snap: snap);
+
+ var info = f.Ingress.Posted.Single(p =>
+ p.Kind == DecisionSignalKind.InformationalEvent
+ && p.Payload != null
+ && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
+ && et == SharedEventTypes.RealmJoinPackageStarted);
+ // Adapter must clamp DisplayName before posting (PII / payload-size guard mirror of
+ // RealmJoinPackageFact.MaxDisplayNameLength = 256). The packageId stays intact.
+ Assert.Equal(256, info.Payload!["displayName"].Length);
+ Assert.Equal("generic-bigname", info.Payload["packageId"]);
+ Assert.Equal("user", info.Payload["scope"]);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs
new file mode 100644
index 00000000..7433f8d4
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs
@@ -0,0 +1,284 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests
+{
+ ///
+ /// Reducer integration tests for the RealmJoin (RJ) completion-gate. The Classic and
+ /// SelfDeploying completion paths must defer terminal transitions while RJ is detected
+ /// and unresolved, and release them once
+ /// arrives or the deadline fires.
+ ///
+ public sealed class RealmJoinGateTests
+ {
+ private static readonly DateTime T0 = new DateTime(2026, 5, 21, 10, 0, 0, DateTimeKind.Utc);
+
+ private static DecisionSignal MakeSignal(
+ long ordinal,
+ DecisionSignalKind kind,
+ DateTime occurredAtUtc,
+ IReadOnlyDictionary? payload = null)
+ {
+ return new DecisionSignal(
+ sessionSignalOrdinal: ordinal,
+ sessionTraceOrdinal: ordinal,
+ kind: kind,
+ kindSchemaVersion: 1,
+ occurredAtUtc: occurredAtUtc,
+ sourceOrigin: "test",
+ evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
+ payload: payload);
+ }
+
+ private static DecisionState PrimeClassicAwaitingDesktop(DecisionEngine engine)
+ {
+ var state = DecisionState.CreateInitial("rj-sess", "rj-tenant", T0);
+ state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
+ state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
+ new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
+ state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
+ new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
+ state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspExiting, T0.AddMinutes(3))).NewState;
+ state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.HelloResolved, T0.AddMinutes(4),
+ new Dictionary { [SignalPayloadKeys.HelloOutcome] = "Success" })).NewState;
+ Assert.Equal(SessionStage.AwaitingDesktop, state.Stage);
+ return state;
+ }
+
+ // ============================================================== Classic flow
+
+ [Fact]
+ public void Baseline_without_realmjoin_detected_completes_normally()
+ {
+ // Regression guard — on devices where RJ is not installed (DetectedUtc stays null),
+ // the gate is open and the Hello+Desktop AND-gate must reach Finalizing exactly as
+ // before Phase A.
+ var engine = new DecisionEngine();
+ var state = PrimeClassicAwaitingDesktop(engine);
+ Assert.Null(state.RealmJoinFacts.DetectedUtc);
+
+ var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.DesktopArrived, T0.AddMinutes(5)));
+
+ Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
+ Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
+ }
+
+ [Fact]
+ public void RealmJoinDetected_arms_the_60_min_hard_timeout_deadline()
+ {
+ var engine = new DecisionEngine();
+ var state = PrimeClassicAwaitingDesktop(engine);
+
+ var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
+ new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" }));
+
+ Assert.NotNull(step.NewState.RealmJoinFacts.DetectedUtc);
+ Assert.Equal(100, step.NewState.RealmJoinFacts.LastDeploymentPhase!.Value);
+
+ var deadline = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
+ // 60-min hard timeout from the post-PrimeClassic AgentBoot anchor (T0) — within rounding.
+ Assert.InRange(deadline.DueAtUtc, T0.AddMinutes(55), T0.AddMinutes(70));
+
+ // ScheduleDeadline effect emitted exactly once.
+ Assert.Single(step.Effects, e =>
+ e.Kind == DecisionEffectKind.ScheduleDeadline &&
+ e.Deadline != null &&
+ e.Deadline.Name == DeadlineNames.RealmJoinTimeout);
+ }
+
+ [Fact]
+ public void RealmJoinGate_closed_defers_finalizing_when_DesktopArrived_arrives_with_hello_already_resolved()
+ {
+ // Sequence: Hello + RealmJoinDetected before Desktop, then DesktopArrived. The AND-gate
+ // would normally TransitionToFinalizing — but the closed gate must keep the session
+ // out of Finalizing until RJ resolves.
+ var engine = new DecisionEngine();
+ var state = PrimeClassicAwaitingDesktop(engine);
+
+ state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
+ new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
+
+ var step = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6)));
+
+ Assert.NotEqual(SessionStage.Finalizing, step.NewState.Stage);
+ Assert.Null(step.NewState.Outcome);
+ Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
+ Assert.NotNull(step.NewState.DesktopArrivedUtc); // fact still recorded
+ }
+
+ [Fact]
+ public void RealmJoinResolved_after_hello_and_desktop_triggers_finalizing_via_classic_path()
+ {
+ // Same setup as the previous test, then RealmJoinResolved (phase 110) lands. The
+ // resolved handler routes through CompleteIfDeferredOrBookkeep → TransitionToFinalizing.
+ var engine = new DecisionEngine();
+ var state = PrimeClassicAwaitingDesktop(engine);
+ state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
+ new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
+ state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
+ Assert.NotEqual(SessionStage.Finalizing, state.Stage);
+
+ var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(7),
+ new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" }));
+
+ Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
+ Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
+ // RealmJoinTimeout deadline got cancelled both in state and as a scheduler effect.
+ Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
+ Assert.Contains(step.Effects, e =>
+ e.Kind == DecisionEffectKind.CancelDeadline &&
+ e.CancelDeadlineName == DeadlineNames.RealmJoinTimeout);
+ Assert.Equal("Resolved", step.NewState.RealmJoinFacts.Outcome!.Value);
+ }
+
+ [Fact]
+ public void RealmJoinTimeout_with_hello_and_desktop_in_completes_with_timeout_outcome()
+ {
+ // Hard 60-min timeout fires while RJ is still incomplete but Hello+Desktop already
+ // landed. Handler routes through CompleteIfDeferredOrBookkeep → TransitionToFinalizing
+ // and records realmjoinOutcome="Timeout".
+ var engine = new DecisionEngine();
+ var state = PrimeClassicAwaitingDesktop(engine);
+ state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
+ new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
+ state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
+
+ var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.DeadlineFired, T0.AddMinutes(65),
+ new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout }));
+
+ Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
+ Assert.Equal("Timeout", step.NewState.RealmJoinFacts.Outcome!.Value);
+
+ // The reducer emitted a realmjoin_timeout timeline entry alongside the
+ // Finalizing-transition effects.
+ Assert.Contains(step.Effects, e =>
+ e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
+ e.Parameters != null &&
+ e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout");
+ }
+
+ // ============================================================== SelfDeploying flow
+
+ [Fact]
+ public void SelfDeploying_DeviceSetupProvisioningComplete_with_realmjoin_open_completes_immediately()
+ {
+ // Baseline — RJ never detected → SelfDeploying terminal path completes directly.
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("rj-sd", "rj-tenant", T0);
+ state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
+ state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
+ new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
+
+ var step = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(2)));
+
+ Assert.Equal(SessionStage.Completed, step.NewState.Stage);
+ Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome);
+ }
+
+ [Fact]
+ public void SelfDeploying_with_realmjoin_detected_defers_terminal_until_resolved()
+ {
+ // SelfDeploying terminal handler observes the gate is closed → marks
+ // SelfDeployingDeferredCompletion on RealmJoinFacts, stays non-terminal, lets the
+ // RealmJoinResolved handler complete directly to Completed (not via Finalizing).
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("rj-sd-2", "rj-tenant", T0);
+ state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
+ state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
+ new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
+ state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2),
+ new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
+
+ var deferred = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState;
+ Assert.NotEqual(SessionStage.Completed, deferred.Stage);
+ Assert.True(deferred.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value);
+
+ var step = engine.Reduce(deferred, MakeSignal(4, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(4),
+ new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" }));
+
+ Assert.Equal(SessionStage.Completed, step.NewState.Stage);
+ Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome);
+ // Direct Completed path emits enrollment_complete inline; no FinalizingGrace deadline
+ // needed because the SelfDeploying handler clears deadlines.
+ Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
+ Assert.Contains(step.Effects, e =>
+ e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
+ e.Parameters != null &&
+ e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete");
+ }
+
+ // ============================================================== Per-package tracking
+
+ [Fact]
+ public void Per_package_started_and_completed_signals_update_RealmJoinFacts_packages()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("rj-pkg", "rj-tenant", T0);
+ state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
+ state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(1),
+ new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
+
+ state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinPackageStarted, T0.AddMinutes(2),
+ new Dictionary
+ {
+ [DecisionEngine.RealmJoinPayloadKeys.PackageId] = "generic-vlc",
+ [DecisionEngine.RealmJoinPayloadKeys.DisplayName] = "VLC media player",
+ [DecisionEngine.RealmJoinPayloadKeys.Version] = "3.0.21.0",
+ [DecisionEngine.RealmJoinPayloadKeys.Scope] = RealmJoinPackageFact.ScopeMachine,
+ })).NewState;
+ Assert.Single(state.RealmJoinFacts.Packages, p => p.PackageId == "generic-vlc" && p.CompletedUtc == null);
+
+ state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.RealmJoinPackageCompleted, T0.AddMinutes(3),
+ new Dictionary
+ {
+ [DecisionEngine.RealmJoinPayloadKeys.PackageId] = "generic-vlc",
+ [DecisionEngine.RealmJoinPayloadKeys.DisplayName] = "VLC media player",
+ [DecisionEngine.RealmJoinPayloadKeys.Scope] = RealmJoinPackageFact.ScopeMachine,
+ [DecisionEngine.RealmJoinPayloadKeys.Success] = "true",
+ [DecisionEngine.RealmJoinPayloadKeys.LastExitCode] = "0",
+ })).NewState;
+ var completed = Assert.Single(state.RealmJoinFacts.Packages, p => p.PackageId == "generic-vlc");
+ Assert.True(completed.Success);
+ Assert.Equal(0, completed.LastExitCode);
+ Assert.NotNull(completed.CompletedUtc);
+ }
+
+ // ============================================================== Audit trail
+
+ [Fact]
+ public void Audit_trail_attaches_realmjoin_fields_when_enrollment_completes_after_resolved()
+ {
+ // After SelfDeploying + RealmJoinResolved, the enrollment_complete effect must carry
+ // the realmjoin* audit-trail fields built by DecisionAuditTrailBuilder.
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("rj-audit", "rj-tenant", T0);
+ state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
+ state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
+ new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
+ state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2),
+ new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
+ state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState;
+
+ var step = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(4),
+ new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" }));
+
+ var completeEffect = Assert.Single(step.Effects, e =>
+ e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
+ e.Parameters != null &&
+ e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete");
+
+ Assert.NotNull(completeEffect.TypedPayload);
+ var data = Assert.IsType>(completeEffect.TypedPayload);
+ Assert.Equal("Resolved", data["realmjoinOutcome"]);
+ Assert.Equal(110, data["realmjoinLastPhase"]);
+ Assert.True(data.ContainsKey("realmjoinDetectedUtc"));
+ Assert.True(data.ContainsKey("realmjoinResolvedUtc"));
+ }
+ }
+}
diff --git a/tests/fixtures/signal-kinds/realmjoin-detected-v1.json b/tests/fixtures/signal-kinds/realmjoin-detected-v1.json
new file mode 100644
index 00000000..d406fabd
--- /dev/null
+++ b/tests/fixtures/signal-kinds/realmjoin-detected-v1.json
@@ -0,0 +1,8 @@
+# RealmJoinDetected v1 — RealmJoin deployment tracking.
+# Posted by RealmJoinWatcherAdapter when HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\
+# Parameters is first observed. Payload carries the current DeploymentPhase DWORD.
+# Reducer: HandleRealmJoinDetectedV1 → records RealmJoinFacts.DetectedUtc + LastDeploymentPhase,
+# arms the 60-min RealmJoinTimeout deadline. The V2 completion AND-gate now reads
+# RealmJoinFacts.DetectedUtc != null and defers Finalizing until RealmJoinResolved /
+# RealmJoinTimeout fires.
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"RealmJoinDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T10:00:00.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-detected-v1","Summary":"RealmJoin Parameters key observed (phase=100)","DerivationInputs":{"registryKey":"HKLM\\SYSTEM\\CurrentControlSet\\Services\\realmjoin\\Parameters"}},"Payload":{"deploymentPhase":"100"}}
diff --git a/tests/fixtures/signal-kinds/realmjoin-package-completed-v1.json b/tests/fixtures/signal-kinds/realmjoin-package-completed-v1.json
new file mode 100644
index 00000000..322692e5
--- /dev/null
+++ b/tests/fixtures/signal-kinds/realmjoin-package-completed-v1.json
@@ -0,0 +1,7 @@
+# RealmJoinPackageCompleted v1 — terminal per-package event.
+# Posted by RealmJoinWatcherAdapter when the matching sub-key gains both Success and
+# LastExitCode values. Payload extends the started shape with success ("true"|"false") +
+# lastExitCode (decimal string of the DWORD).
+# Reducer: HandleRealmJoinPackageCompletedV1 → updates the matching RealmJoinPackageFact
+# row with CompletedUtc / Success / LastExitCode. Stage unchanged.
+{"SessionSignalOrdinal":12,"SessionTraceOrdinal":12,"Kind":"RealmJoinPackageCompleted","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T10:08:00.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-package-completed:machine:generic-vlc","Summary":"RealmJoin package install completed (scope=machine, id=generic-vlc, success=True, exitCode=0)","DerivationInputs":{"registryKey":"HKLM\\SYSTEM\\CurrentControlSet\\Services\\realmjoin\\Parameters"}},"Payload":{"packageId":"generic-vlc","displayName":"VLC media player","version":"3.0.21.0","scope":"machine","success":"true","lastExitCode":"0"}}
diff --git a/tests/fixtures/signal-kinds/realmjoin-package-started-v1.json b/tests/fixtures/signal-kinds/realmjoin-package-started-v1.json
new file mode 100644
index 00000000..4fb96ee6
--- /dev/null
+++ b/tests/fixtures/signal-kinds/realmjoin-package-started-v1.json
@@ -0,0 +1,9 @@
+# RealmJoinPackageStarted v1 — observation-only per-package event.
+# Posted by RealmJoinWatcherAdapter when a HKLM\SOFTWARE\RealmJoin\Packages\ sub-key
+# (or its HKU\\... counterpart) is first observed with a DisplayName value.
+# Payload: packageId (mandatory), displayName (truncated to 256 chars), optional version,
+# scope ("machine"|"user").
+# Reducer: HandleRealmJoinPackageStartedV1 → appends a RealmJoinPackageFact row. Stage
+# unchanged. Dual emission as InformationalEvent realmjoin_package_started handles UI
+# timeline visibility.
+{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"RealmJoinPackageStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T10:05:00.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-package-started:machine:generic-vlc","Summary":"RealmJoin package install started (scope=machine, id=generic-vlc)","DerivationInputs":{"registryKey":"HKLM\\SYSTEM\\CurrentControlSet\\Services\\realmjoin\\Parameters"}},"Payload":{"packageId":"generic-vlc","displayName":"VLC media player","version":"3.0.21.0","scope":"machine"}}
diff --git a/tests/fixtures/signal-kinds/realmjoin-resolved-v1.json b/tests/fixtures/signal-kinds/realmjoin-resolved-v1.json
new file mode 100644
index 00000000..f6a4896f
--- /dev/null
+++ b/tests/fixtures/signal-kinds/realmjoin-resolved-v1.json
@@ -0,0 +1,8 @@
+# RealmJoinResolved v1 — RealmJoin first deployment completed.
+# Posted by RealmJoinWatcherAdapter when DeploymentPhase first reaches
+# CompletedFirstDeployment (110). Payload carries the observed phase.
+# Reducer: HandleRealmJoinResolvedV1 → records RealmJoinFacts.ResolvedUtc/LastPhase/Outcome,
+# cancels the RealmJoinTimeout deadline (scheduler + reducer view), and (if Hello + Desktop
+# already resolved OR SelfDeployingDeferredCompletion is set) routes through
+# CompleteIfDeferredOrBookkeep to TransitionToFinalizing / direct Completed.
+{"SessionSignalOrdinal":42,"SessionTraceOrdinal":42,"Kind":"RealmJoinResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T10:25:00.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-resolved-v1","Summary":"RealmJoin reached phase 110","DerivationInputs":{"registryKey":"HKLM\\SYSTEM\\CurrentControlSet\\Services\\realmjoin\\Parameters"}},"Payload":{"deploymentPhase":"110"}}
diff --git a/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json b/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json
new file mode 100644
index 00000000..fda41e44
--- /dev/null
+++ b/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json
@@ -0,0 +1,8 @@
+# RealmJoin hard-timeout (60-min) deadline fire.
+# This kind is NOT posted by any adapter — it is dispatched by the DeadlineFiredV1 switch
+# (DecisionEngine.Shared.cs) when the RealmJoinTimeout ActiveDeadline armed by
+# HandleRealmJoinDetectedV1 elapses without DeploymentPhase reaching 110.
+# Reducer: HandleRealmJoinTimeoutDeadlineFired → sets RealmJoinFacts.Outcome="Timeout",
+# emits a realmjoin_timeout timeline event, and (if Hello+Desktop already resolved OR
+# SelfDeployingDeferredCompletion is set) routes through CompleteIfDeferredOrBookkeep.
+{"SessionSignalOrdinal":99,"SessionTraceOrdinal":99,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T11:00:00.0000000Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Derived","Identifier":"deadline-realmjoin-timeout","Summary":"RealmJoin hard timeout fired"},"Payload":{"deadline":"realmjoin_timeout"}}