diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs
index b82596c7..1c7787fc 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs
@@ -38,6 +38,13 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// EnrollmentTerminationHandler drains the spool. Plan §5 Fix 6.
///
public const string FinalizingGrace = "finalizing_grace";
+
+ ///
+ /// Hard 60-min timeout from RealmJoin-detected. When fired without phase 110 being
+ /// observed, the gate closes anyway with realmjoinOutcome=Timeout so the
+ /// session can complete (Classic Finalizing or SelfDeploying-direct path).
+ ///
+ public const string RealmJoinTimeout = "realmjoin_timeout";
}
///
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs
index 1431e4a2..26c1b0bc 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs
@@ -131,9 +131,64 @@ namespace AutopilotMonitor.DecisionCore.Engine
data["imePatternMatchedPatternId"] = postState.ImeMatchedPatternId.Value;
}
+ if (postState.RealmJoinFacts.DetectedUtc != null)
+ {
+ data["realmjoinDetectedUtc"] = FormatUtc(postState.RealmJoinFacts.DetectedUtc.Value);
+ data["realmjoinOutcome"] = postState.RealmJoinFacts.Outcome?.Value ?? "Pending";
+ if (postState.RealmJoinFacts.LastDeploymentPhase != null)
+ {
+ data["realmjoinLastPhase"] = postState.RealmJoinFacts.LastDeploymentPhase.Value;
+ }
+ if (postState.RealmJoinFacts.ResolvedUtc != null)
+ {
+ data["realmjoinResolvedUtc"] = FormatUtc(postState.RealmJoinFacts.ResolvedUtc.Value);
+ }
+ data["realmjoinPackages"] = BuildRealmJoinPackageSummary(postState.RealmJoinFacts.Packages);
+ }
+
return data;
}
+ ///
+ /// Build a compact per-package summary list for the realmjoinPackages audit-trail
+ /// field. Each entry carries the identifiers + completion state but omits the per-package
+ /// timestamps to keep the payload size predictable.
+ ///
+ private static List> BuildRealmJoinPackageSummary(
+ IReadOnlyList packages)
+ {
+ var result = new List>(packages.Count);
+ for (var i = 0; i < packages.Count; i++)
+ {
+ var p = packages[i];
+ var row = new Dictionary(capacity: 6, StringComparer.Ordinal)
+ {
+ ["packageId"] = p.PackageId,
+ ["displayName"] = p.DisplayName,
+ ["scope"] = p.Scope,
+ };
+ if (p.Version != null)
+ {
+ row["version"] = p.Version;
+ }
+ if (p.CompletedUtc != null)
+ {
+ row["completedUtc"] = FormatUtc(p.CompletedUtc.Value);
+ row["durationSeconds"] = (int)Math.Max(0, (p.CompletedUtc.Value - p.StartedUtc).TotalSeconds);
+ }
+ if (p.Success != null)
+ {
+ row["success"] = p.Success.Value;
+ }
+ if (p.LastExitCode != null)
+ {
+ row["lastExitCode"] = p.LastExitCode.Value;
+ }
+ result.Add(row);
+ }
+ return result;
+ }
+
///
/// Shorthand to wrap a classifier-verdict signal payload (the parameter dict carried by
/// ) into a typed
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
index 18756ab6..ce6b1a72 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
@@ -228,7 +228,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
// non-terminal Finalizing stage with a short grace period before Completed.
// This lets the backend see phase_transition(FinalizingSetup) + enrollment_complete
// before IsTerminal() fires and EnrollmentTerminationHandler tears down the spool.
- if (desktopAlreadyArrived)
+ //
+ // RealmJoin gate: while RJ is detected and unresolved, the Finalizing transition
+ // is deferred. Hello+Desktop facts are still recorded; the next RealmJoinResolved
+ // or RealmJoinTimeout signal releases the deferred completion via
+ // CompleteIfDeferredOrBookkeep().
+ if (desktopAlreadyArrived && RealmJoinGateOpen(state))
{
return TransitionToFinalizing(
state: state,
@@ -241,6 +246,23 @@ namespace AutopilotMonitor.DecisionCore.Engine
: null);
}
+ if (desktopAlreadyArrived)
+ {
+ // RJ gate closed: defer Finalizing. Stay in the current stage; the RJ
+ // resolved/timeout handler will route through CompleteIfDeferredOrBookkeep().
+ var deferredState = builder.Build();
+ var deferredTransition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.HelloResolved) + ":RealmJoinGateClosed");
+ var deferredEffects = helloSafetyCancelEffect != null
+ ? new[] { helloSafetyCancelEffect }
+ : Array.Empty();
+ return new DecisionStep(deferredState, deferredTransition, deferredEffects);
+ }
+
builder.WithStage(SessionStage.AwaitingDesktop);
var newState = builder.Build();
@@ -340,13 +362,30 @@ namespace AutopilotMonitor.DecisionCore.Engine
};
}
- return TransitionToFinalizing(
- state: state,
+ if (RealmJoinGateOpen(state))
+ {
+ return TransitionToFinalizing(
+ state: state,
+ signal: signal,
+ preparedBuilder: builder,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath",
+ extraLeadingEffects: extraEffects);
+ }
+
+ // RJ gate closed: synthetic Hello + Desktop are recorded; defer Finalizing
+ // until the RJ resolved/timeout handler releases the gate.
+ var fastPathDeferredState = builder.Build();
+ var fastPathDeferredTransition = BuildTakenTransition(
+ before: state,
signal: signal,
- preparedBuilder: builder,
+ toStage: state.Stage,
nextStepIndex: nextStep,
- trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath",
- extraLeadingEffects: extraEffects);
+ trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath:RealmJoinGateClosed");
+ var fastPathDeferredEffects = extraEffects != null
+ ? extraEffects.ToArray()
+ : Array.Empty();
+ return new DecisionStep(fastPathDeferredState, fastPathDeferredTransition, fastPathDeferredEffects);
}
// Plan §5 Fix 6: mirror of HandleHelloResolvedV1. When both prerequisites are in,
@@ -365,15 +404,32 @@ namespace AutopilotMonitor.DecisionCore.Engine
{
builder.CancelDeadline(DeadlineNames.HelloSafety);
}
- return TransitionToFinalizing(
- state: state,
+
+ if (RealmJoinGateOpen(state))
+ {
+ return TransitionToFinalizing(
+ state: state,
+ signal: signal,
+ preparedBuilder: builder,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.DesktopArrived),
+ extraLeadingEffects: helloSafetyCancelEffect != null
+ ? new[] { helloSafetyCancelEffect }
+ : null);
+ }
+
+ // RJ gate closed: Desktop fact is recorded; defer Finalizing.
+ var deferredState = builder.Build();
+ var deferredTransition = BuildTakenTransition(
+ before: state,
signal: signal,
- preparedBuilder: builder,
+ toStage: state.Stage,
nextStepIndex: nextStep,
- trigger: nameof(DecisionSignalKind.DesktopArrived),
- extraLeadingEffects: helloSafetyCancelEffect != null
- ? new[] { helloSafetyCancelEffect }
- : null);
+ trigger: nameof(DecisionSignalKind.DesktopArrived) + ":RealmJoinGateClosed");
+ var deferredEffects = helloSafetyCancelEffect != null
+ ? new[] { helloSafetyCancelEffect }
+ : Array.Empty();
+ return new DecisionStep(deferredState, deferredTransition, deferredEffects);
}
// Desktop came first: keep current stage (AwaitingHello or EspAccountSetup) until
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs
new file mode 100644
index 00000000..94b75e81
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs
@@ -0,0 +1,441 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ // RealmJoin (RJ) deployment-tracking handlers. Plan: tasks/zany-gathering-oasis plan.
+ // The agent posts five new DecisionSignalKinds when it observes the
+ // HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters key + HKLM\SOFTWARE\RealmJoin\Packages
+ // (and the HKU\\... user-scope counterpart). Detection arms a 60-min hard timeout
+ // and gates the enrollment-completion AND-gate. Resolution (phase 110) or timeout
+ // releases the gate so the session can complete.
+ public sealed partial class DecisionEngine
+ {
+ // Hard 60-minute deadline from RJ-detected. Not configurable (per design choice —
+ // the trigger is intentionally aggressive, the timeout has to bound it).
+ private static readonly TimeSpan s_realmJoinHardTimeout = TimeSpan.FromMinutes(60);
+
+ // Payload keys consumed by the new handlers (set by the agent's RealmJoinWatcherAdapter).
+ internal static class RealmJoinPayloadKeys
+ {
+ public const string DeploymentPhase = "deploymentPhase";
+ public const string PackageId = "packageId";
+ public const string DisplayName = "displayName";
+ public const string Version = "version";
+ public const string Scope = "scope"; // "machine" | "user"
+ public const string Success = "success"; // "true" | "false"
+ public const string LastExitCode = "lastExitCode";
+ }
+
+ ///
+ /// Returns true when the RJ gate is OPEN — either RJ was never detected or it
+ /// has already resolved / timed out (Outcome set). The Classic and SelfDeploying
+ /// completion paths AND this with their existing predicates so an active RJ
+ /// deployment blocks and the SelfDeploying
+ /// terminal transition.
+ ///
+ internal static bool RealmJoinGateOpen(DecisionState state) =>
+ state.RealmJoinFacts.DetectedUtc == null
+ || state.RealmJoinFacts.ResolvedUtc != null
+ || state.RealmJoinFacts.Outcome != null;
+
+ ///
+ /// Build the 60-min RJ hard-timeout deadline. Floored at
+ /// via so a replayed RealmJoinDetected signal cannot
+ /// collapse the timer into immediate-fire at boot.
+ ///
+ private static ActiveDeadline BuildRealmJoinTimeoutDeadline(DateTime fromUtc) =>
+ new ActiveDeadline(
+ name: DeadlineNames.RealmJoinTimeout,
+ dueAtUtc: fromUtc.Add(s_realmJoinHardTimeout),
+ firesSignalKind: DecisionSignalKind.DeadlineFired,
+ firesPayload: new Dictionary
+ {
+ [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout,
+ });
+
+ ///
+ /// First-observation handler for the RealmJoin Parameters registry key. Records
+ /// , captures the initial DeploymentPhase
+ /// observation (if present in the payload), and arms the 60-min hard timeout.
+ /// Idempotent — a second RealmJoinDetected signal in the same session is a bookkeeping
+ /// no-op (the set-once helpers on guard the writes).
+ ///
+ private DecisionStep HandleRealmJoinDetectedV1(DecisionState state, DecisionSignal signal)
+ {
+ var nextStep = state.StepIndex + 1;
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
+
+ var alreadyDetected = state.RealmJoinFacts.DetectedUtc != null;
+
+ var phase = TryReadPhase(signal);
+ var updatedFacts = state.RealmJoinFacts.WithDetected(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
+ if (phase.HasValue)
+ {
+ updatedFacts = updatedFacts.WithLastPhase(phase.Value, signal.SessionSignalOrdinal);
+ }
+ builder.RealmJoinFacts = updatedFacts;
+
+ var effects = Array.Empty();
+ if (!alreadyDetected)
+ {
+ var deadline = BuildRealmJoinTimeoutDeadline(EffectiveDeadlineBase(state, signal));
+ builder.AddDeadline(deadline);
+ effects = new[]
+ {
+ new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: deadline),
+ };
+ }
+
+ var newState = builder.Build();
+
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.RealmJoinDetected));
+
+ return new DecisionStep(newState, transition, effects);
+ }
+
+ ///
+ /// RealmJoin reached DeploymentPhase = CompletedFirstDeployment (110). Cancels
+ /// the hard-timeout deadline (live scheduler + reducer view) and, when the other
+ /// completion preconditions are already in, releases the deferred completion path:
+ /// Classic → ; SelfDeploying → direct
+ /// + enrollment_complete.
+ ///
+ private DecisionStep HandleRealmJoinResolvedV1(DecisionState state, DecisionSignal signal)
+ {
+ if (state.RealmJoinFacts.DetectedUtc == null)
+ {
+ // Defensive: Resolved without Detected. Record the resolution so the audit trail
+ // captures the pre-existing-110 case (RJ already done before agent boot), but
+ // skip the deadline-cancel since none was armed.
+ var preBuilder = state.ToBuilder()
+ .WithStepIndex(state.StepIndex + 1)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
+ preBuilder.RealmJoinFacts = state.RealmJoinFacts
+ .WithDetected(signal.OccurredAtUtc, signal.SessionSignalOrdinal)
+ .WithResolved(signal.OccurredAtUtc, 110, signal.SessionSignalOrdinal);
+ var preState = preBuilder.Build();
+ var preTransition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: preState.StepIndex,
+ trigger: nameof(DecisionSignalKind.RealmJoinResolved) + ":WithoutDetected");
+ return new DecisionStep(preState, preTransition, Array.Empty());
+ }
+
+ var nextStep = state.StepIndex + 1;
+ var phase = TryReadPhase(signal) ?? 110;
+
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
+ .CancelDeadline(DeadlineNames.RealmJoinTimeout);
+ builder.RealmJoinFacts = state.RealmJoinFacts.WithResolved(signal.OccurredAtUtc, phase, signal.SessionSignalOrdinal);
+
+ var cancelEffect = BuildRealmJoinTimeoutCancelEffectIfArmed(state);
+
+ return CompleteIfDeferredOrBookkeep(
+ state: state,
+ signal: signal,
+ preparedBuilder: builder,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.RealmJoinResolved),
+ leadingEffect: cancelEffect);
+ }
+
+ ///
+ /// 60-min hard-timeout fired without RJ reaching phase 110. Records
+ /// = "Timeout", emits a
+ /// realmjoin_timeout timeline entry, and — when other completion preconditions
+ /// are in — releases the deferred completion path.
+ ///
+ private DecisionStep HandleRealmJoinTimeoutDeadlineFired(DecisionState state, DecisionSignal signal)
+ {
+ var nextStep = state.StepIndex + 1;
+
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
+ .CancelDeadline(DeadlineNames.RealmJoinTimeout);
+
+ builder.RealmJoinFacts = state.RealmJoinFacts.WithTimeoutOutcome(signal.SessionSignalOrdinal);
+
+ var timeoutEffect = BuildRealmJoinTimeoutEvent(state);
+
+ return CompleteIfDeferredOrBookkeep(
+ state: state,
+ signal: signal,
+ preparedBuilder: builder,
+ nextStepIndex: nextStep,
+ trigger: $"DeadlineFired:{DeadlineNames.RealmJoinTimeout}",
+ leadingEffect: timeoutEffect);
+ }
+
+ ///
+ /// Per-package install start. Observation-only: appends a row to
+ /// . Stage unchanged, no effects (the agent
+ /// adapter dual-emits an for the
+ /// UI timeline).
+ ///
+ private DecisionStep HandleRealmJoinPackageStartedV1(DecisionState state, DecisionSignal signal)
+ {
+ var packageId = TryReadString(signal, RealmJoinPayloadKeys.PackageId);
+ if (string.IsNullOrEmpty(packageId))
+ {
+ var deadEnd = BumpStepBookkeeping(state, signal);
+ var deadEndTransition = BuildDeadEndTransition(
+ state: state,
+ signal: signal,
+ nextStepIndex: deadEnd.StepIndex,
+ trigger: nameof(DecisionSignalKind.RealmJoinPackageStarted),
+ deadEndReason: "realmjoin_package_started_missing_packageId");
+ return new DecisionStep(deadEnd, deadEndTransition, Array.Empty());
+ }
+
+ var displayName = TryReadString(signal, RealmJoinPayloadKeys.DisplayName) ?? string.Empty;
+ var version = TryReadString(signal, RealmJoinPayloadKeys.Version);
+ var scope = TryReadString(signal, RealmJoinPayloadKeys.Scope) ?? RealmJoinPackageFact.ScopeMachine;
+
+ var nextStep = state.StepIndex + 1;
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
+ builder.RealmJoinFacts = state.RealmJoinFacts.WithPackageStarted(
+ packageId: packageId!,
+ displayName: displayName,
+ version: version,
+ scope: scope,
+ startedUtc: signal.OccurredAtUtc);
+
+ var newState = builder.Build();
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.RealmJoinPackageStarted));
+
+ return new DecisionStep(newState, transition, Array.Empty());
+ }
+
+ ///
+ /// Per-package install terminal outcome. Updates the matching row in
+ /// with success / lastExitCode / completedUtc.
+ /// Stage unchanged.
+ ///
+ private DecisionStep HandleRealmJoinPackageCompletedV1(DecisionState state, DecisionSignal signal)
+ {
+ var packageId = TryReadString(signal, RealmJoinPayloadKeys.PackageId);
+ if (string.IsNullOrEmpty(packageId))
+ {
+ var deadEnd = BumpStepBookkeeping(state, signal);
+ var deadEndTransition = BuildDeadEndTransition(
+ state: state,
+ signal: signal,
+ nextStepIndex: deadEnd.StepIndex,
+ trigger: nameof(DecisionSignalKind.RealmJoinPackageCompleted),
+ deadEndReason: "realmjoin_package_completed_missing_packageId");
+ return new DecisionStep(deadEnd, deadEndTransition, Array.Empty());
+ }
+
+ var displayName = TryReadString(signal, RealmJoinPayloadKeys.DisplayName) ?? string.Empty;
+ var version = TryReadString(signal, RealmJoinPayloadKeys.Version);
+ var scope = TryReadString(signal, RealmJoinPayloadKeys.Scope) ?? RealmJoinPackageFact.ScopeMachine;
+ var success = TryReadBool(signal, RealmJoinPayloadKeys.Success) ?? false;
+ var lastExitCode = TryReadInt(signal, RealmJoinPayloadKeys.LastExitCode) ?? 0;
+
+ var nextStep = state.StepIndex + 1;
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
+ builder.RealmJoinFacts = state.RealmJoinFacts.WithPackageCompleted(
+ packageId: packageId!,
+ displayName: displayName,
+ version: version,
+ scope: scope,
+ completedUtc: signal.OccurredAtUtc,
+ success: success,
+ lastExitCode: lastExitCode);
+
+ var newState = builder.Build();
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.RealmJoinPackageCompleted));
+
+ return new DecisionStep(newState, transition, Array.Empty());
+ }
+
+ // ============================================================== internal helpers
+
+ ///
+ /// Shared completion-release helper used by and
+ /// . Routes to one of three outcomes
+ /// depending on which other completion preconditions are present:
+ ///
+ /// - SelfDeploying deferred ():
+ /// direct + enrollment_complete.
+ /// - Classic both-resolved (Hello + Desktop both in):
+ /// .
+ /// - Neither: bookkeeping only — the next Hello / Desktop / SelfDeploying
+ /// signal will trigger completion through the standard AND-gate (which now reads
+ /// the gate as open since or
+ /// is set).
+ ///
+ ///
+ private DecisionStep CompleteIfDeferredOrBookkeep(
+ DecisionState state,
+ DecisionSignal signal,
+ DecisionStateBuilder preparedBuilder,
+ int nextStepIndex,
+ string trigger,
+ DecisionEffect? leadingEffect)
+ {
+ var selfDeployingDeferred = state.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value == true;
+ var classicReady = state.HelloResolvedUtc != null && state.DesktopArrivedUtc != null;
+
+ if (selfDeployingDeferred)
+ {
+ preparedBuilder
+ .WithStage(SessionStage.Completed)
+ .WithOutcome(SessionOutcome.EnrollmentComplete)
+ .ClearDeadlines();
+ var completedState = preparedBuilder.Build();
+ var completedTransition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: SessionStage.Completed,
+ nextStepIndex: nextStepIndex,
+ trigger: trigger + ":SelfDeployingDeferred");
+
+ var effects = new List(capacity: 2);
+ if (leadingEffect != null) effects.Add(leadingEffect);
+ effects.Add(BuildEnrollmentCompleteEffect(completedState, trigger + ":SelfDeployingDeferred"));
+
+ return new DecisionStep(completedState, completedTransition, effects.ToArray());
+ }
+
+ if (classicReady)
+ {
+ var extra = leadingEffect != null ? new[] { leadingEffect } : null;
+ return TransitionToFinalizing(
+ state: state,
+ signal: signal,
+ preparedBuilder: preparedBuilder,
+ nextStepIndex: nextStepIndex,
+ trigger: trigger,
+ extraLeadingEffects: extra);
+ }
+
+ // Bookkeeping only — defer to the next Hello / Desktop / SelfDeploying signal.
+ var newState = preparedBuilder.Build();
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStepIndex,
+ trigger: trigger);
+
+ var bookkeepEffects = leadingEffect != null
+ ? new[] { leadingEffect }
+ : Array.Empty();
+ return new DecisionStep(newState, transition, bookkeepEffects);
+ }
+
+ ///
+ /// Emit a scheduler-visible for
+ /// when it is actually armed in
+ /// . Same pattern as BuildHelloSafetyCancelEffectIfArmed:
+ /// avoids spurious scheduler noise on the cancel path when the timer was never armed.
+ ///
+ private static DecisionEffect? BuildRealmJoinTimeoutCancelEffectIfArmed(DecisionState state)
+ {
+ foreach (var d in state.Deadlines)
+ {
+ if (d.Name == DeadlineNames.RealmJoinTimeout)
+ {
+ return new DecisionEffect(
+ DecisionEffectKind.CancelDeadline,
+ cancelDeadlineName: DeadlineNames.RealmJoinTimeout);
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Build the realmjoin_timeout timeline-entry effect emitted by the deadline-
+ /// fired handler. Unlike /
+ /// (which the agent dual-emits as
+ /// InformationalEvent), the timeout is a synthetic deadline so the reducer owns its
+ /// timeline visibility.
+ ///
+ private static DecisionEffect BuildRealmJoinTimeoutEvent(DecisionState state)
+ {
+ var facts = state.RealmJoinFacts;
+ var lastPhase = facts.LastDeploymentPhase?.Value ?? 0;
+ var tracked = facts.Packages.Count;
+ var completed = 0;
+ for (var i = 0; i < facts.Packages.Count; i++)
+ {
+ if (facts.Packages[i].CompletedUtc != null) completed++;
+ }
+
+ return new DecisionEffect(
+ kind: DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: new Dictionary(StringComparer.Ordinal)
+ {
+ [SignalPayloadKeys.EventType] = "realmjoin_timeout",
+ [SignalPayloadKeys.Source] = "DecisionEngine",
+ [SignalPayloadKeys.Severity] = "Warning",
+ [SignalPayloadKeys.Message] = $"RealmJoin did not reach phase 110 within 60 min (last phase: {lastPhase}).",
+ ["lastSeenPhase"] = lastPhase.ToString(CultureInfo.InvariantCulture),
+ ["packagesTracked"] = tracked.ToString(CultureInfo.InvariantCulture),
+ ["packagesCompleted"] = completed.ToString(CultureInfo.InvariantCulture),
+ });
+ }
+
+ // ---- payload helpers -------------------------------------------------------------
+
+ private static int? TryReadPhase(DecisionSignal signal)
+ {
+ if (signal.Payload == null) return null;
+ if (!signal.Payload.TryGetValue(RealmJoinPayloadKeys.DeploymentPhase, out var raw)) return null;
+ if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var phase)) return phase;
+ return null;
+ }
+
+ private static string? TryReadString(DecisionSignal signal, string key)
+ {
+ if (signal.Payload == null) return null;
+ return signal.Payload.TryGetValue(key, out var v) ? v : null;
+ }
+
+ private static bool? TryReadBool(DecisionSignal signal, string key)
+ {
+ if (signal.Payload == null) return null;
+ if (!signal.Payload.TryGetValue(key, out var raw)) return null;
+ return bool.TryParse(raw, out var b) ? (bool?)b : null;
+ }
+
+ private static int? TryReadInt(DecisionSignal signal, string key)
+ {
+ if (signal.Payload == null) return null;
+ if (!signal.Payload.TryGetValue(key, out var raw)) return null;
+ return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? (int?)v : null;
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs
index 774ce25c..b590bbfa 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs
@@ -83,6 +83,31 @@ namespace AutopilotMonitor.DecisionCore.Engine
score: 100,
lastUpdatedUtc: signal.OccurredAtUtc);
+ // RealmJoin gate: when RJ is detected and unresolved, defer the SelfDeploying
+ // terminal transition. Record the classifier verdict + the deferred-completion
+ // marker on RealmJoinFacts so HandleRealmJoinResolvedV1 / HandleRealmJoinTimeoutDeadlineFired
+ // can short-circuit straight to Completed without re-routing through Classic
+ // Finalizing.
+ if (!RealmJoinGateOpen(state))
+ {
+ var deferredBuilder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
+ deferredBuilder.ClassifierOutcomes = state.ClassifierOutcomes.WithDeviceOnlyDeployment(updatedDeviceOnly);
+ deferredBuilder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyDeviceSetupProvisioningComplete(
+ deferredBuilder.ScenarioProfile, signal);
+ deferredBuilder.RealmJoinFacts = state.RealmJoinFacts.WithSelfDeployingDeferred(signal.SessionSignalOrdinal);
+
+ var deferredState = deferredBuilder.Build();
+ var deferredTransition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete) + ":RealmJoinGateClosed");
+ return new DecisionStep(deferredState, deferredTransition, Array.Empty());
+ }
+
var builder = state.ToBuilder()
.WithStage(SessionStage.Completed)
.WithOutcome(SessionOutcome.EnrollmentComplete)
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
index 8fa814de..7a29040f 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
@@ -344,6 +344,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
return HandleClassifierTickDeadlineFired(state, signal);
case DeadlineNames.FinalizingGrace:
return HandleFinalizingGraceDeadlineFired(state, signal);
+ case DeadlineNames.RealmJoinTimeout:
+ return HandleRealmJoinTimeoutDeadlineFired(state, signal);
default:
// Deadline name not recognized in this sub-milestone. Cancel it from state
// and record a neutral taken transition — M3.3+ adds handlers for
@@ -392,7 +394,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
// Plan §5 Fix 6: route the both-prerequisites case through Finalizing so the
// synthetic-timeout terminal event shares the same grace window + phase-declaration
// pathway as the happy-path handlers. AwaitingDesktop path is unchanged.
- if (desktopAlreadyArrived)
+ //
+ // RealmJoin gate: while RJ is detected and unresolved, defer Finalizing. The
+ // synthetic Hello-timeout fact stays recorded; the RJ resolved/timeout handler
+ // routes through CompleteIfDeferredOrBookkeep when both gates are ready.
+ if (desktopAlreadyArrived && RealmJoinGateOpen(state))
{
return TransitionToFinalizing(
state: state,
@@ -402,6 +408,18 @@ namespace AutopilotMonitor.DecisionCore.Engine
trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}");
}
+ if (desktopAlreadyArrived)
+ {
+ var deferredState = builder.Build();
+ var deferredTransition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}:RealmJoinGateClosed");
+ return new DecisionStep(deferredState, deferredTransition, Array.Empty());
+ }
+
builder.WithStage(SessionStage.AwaitingDesktop);
var newState = builder.Build();
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
index bfd4b000..3a35f828 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
@@ -120,6 +120,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
(DecisionSignalKind.AppInstallCompleted, 1) => HandleAppInstallCompletedV1(state, signal),
(DecisionSignalKind.AppInstallFailed, 1) => HandleAppInstallFailedV1(state, signal),
+ // ----- RealmJoin deployment tracking (DecisionEngine.RealmJoin.cs) -----
+ (DecisionSignalKind.RealmJoinDetected, 1) => HandleRealmJoinDetectedV1(state, signal),
+ (DecisionSignalKind.RealmJoinResolved, 1) => HandleRealmJoinResolvedV1(state, signal),
+ (DecisionSignalKind.RealmJoinPackageStarted, 1) => HandleRealmJoinPackageStartedV1(state, signal),
+ (DecisionSignalKind.RealmJoinPackageCompleted, 1) => HandleRealmJoinPackageCompletedV1(state, signal),
+ // Note: RealmJoinTimeout is a deadline-only kind (no direct adapter signal) —
+ // dispatched via DeadlineFiredV1 switch in DecisionEngine.Shared.cs.
+
// ----- Fall-through: unknown (kind, schemaVersion) pair → dead-end journal entry -----
_ => HandleUnhandledSignal(state, signal),
};
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
index 8e9e0f74..68c4169d 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
@@ -94,5 +94,16 @@ namespace AutopilotMonitor.DecisionCore.Signals
// and add a state-mutating reducer case. Emission shape and UI contract stay the
// same because the effect parameters carry the same EnrollmentEvent fields.
InformationalEvent,
+
+ // --- RealmJoin (RJ) deployment tracking ---
+ // RealmJoin is a third-party deployment agent that installs additional software
+ // after Autopilot reaches the desktop. When detected, the V2 engine keeps the
+ // session non-terminal until DeploymentPhase reaches CompletedFirstDeployment (110)
+ // or the 60-min hard timeout fires.
+ RealmJoinDetected,
+ RealmJoinResolved,
+ RealmJoinTimeout,
+ RealmJoinPackageStarted,
+ RealmJoinPackageCompleted,
}
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
index 700fc0d7..7a74507f 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
@@ -30,7 +30,7 @@ namespace AutopilotMonitor.DecisionCore.State
///
public sealed class DecisionState
{
- public const string CurrentSchemaVersion = "v2";
+ public const string CurrentSchemaVersion = "v3";
public DecisionState(
string sessionId,
@@ -58,6 +58,7 @@ namespace AutopilotMonitor.DecisionCore.State
SignalFact? helloPolicyEnabled = null,
DateTime? agentBootUtc = null,
SignalFact? lastFailureTrigger = null,
+ RealmJoinFacts? realmJoinFacts = null,
string? schemaVersion = null)
{
if (string.IsNullOrEmpty(sessionId))
@@ -95,6 +96,7 @@ namespace AutopilotMonitor.DecisionCore.State
HelloPolicyEnabled = helloPolicyEnabled;
AgentBootUtc = agentBootUtc;
LastFailureTrigger = lastFailureTrigger;
+ RealmJoinFacts = realmJoinFacts ?? RealmJoinFacts.Empty;
SchemaVersion = schemaVersion ?? CurrentSchemaVersion;
}
@@ -219,6 +221,16 @@ namespace AutopilotMonitor.DecisionCore.State
///
public SignalFact? LastFailureTrigger { get; }
+ ///
+ /// Aggregated facts about a RealmJoin (RJ) deployment observed during this session.
+ /// Never null; defaults to . The V2 AND-gate
+ /// (Classic and SelfDeploying) extends the session lifetime while
+ /// is set and
+ /// is not — driven by the new HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters
+ /// + HKLM\SOFTWARE\RealmJoin\Packages\* registry collectors.
+ ///
+ public RealmJoinFacts RealmJoinFacts { get; }
+
public string SchemaVersion { get; }
///
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
index 53a242c5..eb089f6a 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
@@ -44,6 +44,7 @@ namespace AutopilotMonitor.DecisionCore.State
HelloPolicyEnabled = source.HelloPolicyEnabled;
AgentBootUtc = source.AgentBootUtc;
LastFailureTrigger = source.LastFailureTrigger;
+ RealmJoinFacts = source.RealmJoinFacts;
SchemaVersion = source.SchemaVersion;
}
@@ -72,6 +73,7 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact? HelloPolicyEnabled { get; set; }
public DateTime? AgentBootUtc { get; set; }
public SignalFact? LastFailureTrigger { get; set; }
+ public RealmJoinFacts RealmJoinFacts { get; set; } = RealmJoinFacts.Empty;
public string SchemaVersion { get; set; }
// ---------- fluent helpers for the most common reducer operations ----------
@@ -121,6 +123,12 @@ namespace AutopilotMonitor.DecisionCore.State
return this;
}
+ public DecisionStateBuilder WithRealmJoinFacts(RealmJoinFacts facts)
+ {
+ RealmJoinFacts = facts ?? throw new ArgumentNullException(nameof(facts));
+ return this;
+ }
+
public DecisionStateBuilder WithScenarioProfile(EnrollmentScenarioProfile profile)
{
ScenarioProfile = profile ?? throw new ArgumentNullException(nameof(profile));
@@ -200,6 +208,7 @@ namespace AutopilotMonitor.DecisionCore.State
helloPolicyEnabled: HelloPolicyEnabled,
agentBootUtc: AgentBootUtc,
lastFailureTrigger: LastFailureTrigger,
+ realmJoinFacts: RealmJoinFacts,
schemaVersion: SchemaVersion);
}
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs
new file mode 100644
index 00000000..1d9cc768
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs
@@ -0,0 +1,256 @@
+using System;
+using System.Collections.Generic;
+
+namespace AutopilotMonitor.DecisionCore.State
+{
+ ///
+ /// Aggregated facts about a RealmJoin (RJ) deployment observed during a session.
+ /// Drives the V2 completion-gate extension: when RJ is detected the AND-gate keeps
+ /// the session non-terminal until either
+ /// is observed or the 60-min hard timeout elapses.
+ ///
+ ///
+ /// Invariants:
+ ///
+ /// - Immutable; -methods return new instances.
+ /// - is capped at ; overflow is discarded.
+ /// - is set once on observing phase 110; later phase readings update only.
+ /// - is one of "Resolved" / "Timeout"; null while RJ is still running.
+ ///
+ ///
+ public sealed class RealmJoinFacts
+ {
+ /// Cap on so pathological deployments cannot bloat state.
+ public const int MaxPackages = 200;
+
+ public const string OutcomeResolved = "Resolved";
+ public const string OutcomeTimeout = "Timeout";
+
+ public static readonly RealmJoinFacts Empty = new RealmJoinFacts(
+ detectedUtc: null,
+ resolvedUtc: null,
+ lastDeploymentPhase: null,
+ outcome: null,
+ selfDeployingDeferredCompletion: null,
+ packages: Array.Empty());
+
+ public RealmJoinFacts(
+ SignalFact? detectedUtc,
+ SignalFact? resolvedUtc,
+ SignalFact? lastDeploymentPhase,
+ SignalFact? outcome,
+ SignalFact? selfDeployingDeferredCompletion,
+ IReadOnlyList packages)
+ {
+ DetectedUtc = detectedUtc;
+ ResolvedUtc = resolvedUtc;
+ LastDeploymentPhase = lastDeploymentPhase;
+ Outcome = outcome;
+ SelfDeployingDeferredCompletion = selfDeployingDeferredCompletion;
+ Packages = packages ?? Array.Empty();
+ }
+
+ /// First-observation timestamp of the HKLM\SYSTEM\...\realmjoin\Parameters key.
+ public SignalFact? DetectedUtc { get; }
+
+ /// Set once when first reaches 110.
+ public SignalFact? ResolvedUtc { get; }
+
+ /// Most recent DeploymentPhase DWORD observed in the Parameters key.
+ public SignalFact? LastDeploymentPhase { get; }
+
+ /// Terminal outcome string: or .
+ public SignalFact? Outcome { get; }
+
+ ///
+ /// Indicates the SelfDeploying terminal path ()
+ /// was observed but blocked by an active RealmJoin gate. The RealmJoin
+ /// resolved/timeout handlers use this flag to complete the SelfDeploying path
+ /// directly without re-entering the Classic Finalizing pipeline.
+ ///
+ public SignalFact? SelfDeployingDeferredCompletion { get; }
+
+ /// Tracked per-package install rows (machine + user scope combined).
+ public IReadOnlyList Packages { get; }
+
+ public RealmJoinFacts WithDetected(DateTime utc, long sourceSignalOrdinal)
+ {
+ if (DetectedUtc != null) return this; // set-once
+ return new RealmJoinFacts(
+ detectedUtc: new SignalFact(utc, sourceSignalOrdinal),
+ resolvedUtc: ResolvedUtc,
+ lastDeploymentPhase: LastDeploymentPhase,
+ outcome: Outcome,
+ selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
+ packages: Packages);
+ }
+
+ public RealmJoinFacts WithResolved(DateTime utc, int phase, long sourceSignalOrdinal)
+ {
+ if (ResolvedUtc != null) return this; // set-once
+ return new RealmJoinFacts(
+ detectedUtc: DetectedUtc,
+ resolvedUtc: new SignalFact(utc, sourceSignalOrdinal),
+ lastDeploymentPhase: new SignalFact(phase, sourceSignalOrdinal),
+ outcome: new SignalFact(OutcomeResolved, sourceSignalOrdinal),
+ selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
+ packages: Packages);
+ }
+
+ public RealmJoinFacts WithLastPhase(int phase, long sourceSignalOrdinal)
+ {
+ if (LastDeploymentPhase != null && LastDeploymentPhase.Value == phase) return this;
+ return new RealmJoinFacts(
+ detectedUtc: DetectedUtc,
+ resolvedUtc: ResolvedUtc,
+ lastDeploymentPhase: new SignalFact(phase, sourceSignalOrdinal),
+ outcome: Outcome,
+ selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
+ packages: Packages);
+ }
+
+ public RealmJoinFacts WithTimeoutOutcome(long sourceSignalOrdinal)
+ {
+ if (Outcome != null) return this; // resolved already wins
+ return new RealmJoinFacts(
+ detectedUtc: DetectedUtc,
+ resolvedUtc: ResolvedUtc,
+ lastDeploymentPhase: LastDeploymentPhase,
+ outcome: new SignalFact(OutcomeTimeout, sourceSignalOrdinal),
+ selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
+ packages: Packages);
+ }
+
+ public RealmJoinFacts WithSelfDeployingDeferred(long sourceSignalOrdinal)
+ {
+ if (SelfDeployingDeferredCompletion != null) return this;
+ return new RealmJoinFacts(
+ detectedUtc: DetectedUtc,
+ resolvedUtc: ResolvedUtc,
+ lastDeploymentPhase: LastDeploymentPhase,
+ outcome: Outcome,
+ selfDeployingDeferredCompletion: new SignalFact(true, sourceSignalOrdinal),
+ packages: Packages);
+ }
+
+ ///
+ /// Append a new package row when the package is first observed (DisplayName seen,
+ /// no Success/LastExitCode yet). Idempotent on (,
+ /// ) — a second start for the same pair is ignored.
+ ///
+ public RealmJoinFacts WithPackageStarted(
+ string packageId,
+ string displayName,
+ string? version,
+ string scope,
+ DateTime startedUtc)
+ {
+ if (string.IsNullOrEmpty(packageId) || string.IsNullOrEmpty(scope)) return this;
+
+ for (var i = 0; i < Packages.Count; i++)
+ {
+ var p = Packages[i];
+ if (string.Equals(p.PackageId, packageId, StringComparison.Ordinal) &&
+ string.Equals(p.Scope, scope, StringComparison.Ordinal))
+ {
+ return this; // already tracked
+ }
+ }
+
+ if (Packages.Count >= MaxPackages) return this;
+
+ var truncated = displayName ?? string.Empty;
+ if (truncated.Length > RealmJoinPackageFact.MaxDisplayNameLength)
+ {
+ truncated = truncated.Substring(0, RealmJoinPackageFact.MaxDisplayNameLength);
+ }
+
+ var copy = new List(Packages.Count + 1);
+ copy.AddRange(Packages);
+ copy.Add(new RealmJoinPackageFact(
+ packageId: packageId,
+ displayName: truncated,
+ version: version,
+ scope: scope,
+ startedUtc: startedUtc,
+ completedUtc: null,
+ success: null,
+ lastExitCode: null));
+
+ return new RealmJoinFacts(
+ detectedUtc: DetectedUtc,
+ resolvedUtc: ResolvedUtc,
+ lastDeploymentPhase: LastDeploymentPhase,
+ outcome: Outcome,
+ selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
+ packages: copy);
+ }
+
+ ///
+ /// Update an existing package row with its terminal outcome. If the package wasn't
+ /// previously seen (rare race), a new row is created with Started = Completed and
+ /// only the terminal fields populated.
+ ///
+ public RealmJoinFacts WithPackageCompleted(
+ string packageId,
+ string displayName,
+ string? version,
+ string scope,
+ DateTime completedUtc,
+ bool success,
+ int lastExitCode)
+ {
+ if (string.IsNullOrEmpty(packageId) || string.IsNullOrEmpty(scope)) return this;
+
+ var truncated = displayName ?? string.Empty;
+ if (truncated.Length > RealmJoinPackageFact.MaxDisplayNameLength)
+ {
+ truncated = truncated.Substring(0, RealmJoinPackageFact.MaxDisplayNameLength);
+ }
+
+ var copy = new List(Packages.Count);
+ var updated = false;
+ for (var i = 0; i < Packages.Count; i++)
+ {
+ var p = Packages[i];
+ if (!updated &&
+ string.Equals(p.PackageId, packageId, StringComparison.Ordinal) &&
+ string.Equals(p.Scope, scope, StringComparison.Ordinal))
+ {
+ if (p.CompletedUtc != null)
+ {
+ return this; // already completed; ignore duplicate
+ }
+ copy.Add(p.WithCompletion(completedUtc, success, lastExitCode));
+ updated = true;
+ }
+ else
+ {
+ copy.Add(p);
+ }
+ }
+
+ if (!updated)
+ {
+ if (Packages.Count >= MaxPackages) return this;
+ copy.Add(new RealmJoinPackageFact(
+ packageId: packageId,
+ displayName: truncated,
+ version: version,
+ scope: scope,
+ startedUtc: completedUtc,
+ completedUtc: completedUtc,
+ success: success,
+ lastExitCode: lastExitCode));
+ }
+
+ return new RealmJoinFacts(
+ detectedUtc: DetectedUtc,
+ resolvedUtc: ResolvedUtc,
+ lastDeploymentPhase: LastDeploymentPhase,
+ outcome: Outcome,
+ selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
+ packages: copy);
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinPackageFact.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinPackageFact.cs
new file mode 100644
index 00000000..597df334
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinPackageFact.cs
@@ -0,0 +1,77 @@
+using System;
+
+namespace AutopilotMonitor.DecisionCore.State
+{
+ ///
+ /// Snapshot of a single RealmJoin package install observed via the
+ /// HKLM\SOFTWARE\RealmJoin\Packages\<packageId> (machine scope) or
+ /// HKEY_USERS\<sid>\SOFTWARE\RealmJoin\Packages\<packageId> (user scope)
+ /// registry hierarchy. Held inside .
+ ///
+ ///
+ /// is truncated to characters
+ /// by the adapter before being recorded. /
+ /// / are null while the package is still installing.
+ ///
+ public sealed class RealmJoinPackageFact
+ {
+ public const int MaxDisplayNameLength = 256;
+
+ /// Machine-scope registration (HKLM\SOFTWARE\RealmJoin\Packages).
+ public const string ScopeMachine = "machine";
+
+ /// User-scope registration (HKU\<sid>\SOFTWARE\RealmJoin\Packages).
+ public const string ScopeUser = "user";
+
+ public RealmJoinPackageFact(
+ string packageId,
+ string displayName,
+ string? version,
+ string scope,
+ DateTime startedUtc,
+ DateTime? completedUtc,
+ bool? success,
+ int? lastExitCode)
+ {
+ if (string.IsNullOrEmpty(packageId)) throw new ArgumentException("PackageId is mandatory.", nameof(packageId));
+ if (string.IsNullOrEmpty(scope)) throw new ArgumentException("Scope is mandatory.", nameof(scope));
+
+ PackageId = packageId;
+ DisplayName = displayName ?? string.Empty;
+ Version = version;
+ Scope = scope;
+ StartedUtc = startedUtc;
+ CompletedUtc = completedUtc;
+ Success = success;
+ LastExitCode = lastExitCode;
+ }
+
+ public string PackageId { get; }
+
+ public string DisplayName { get; }
+
+ public string? Version { get; }
+
+ /// Either or .
+ public string Scope { get; }
+
+ public DateTime StartedUtc { get; }
+
+ public DateTime? CompletedUtc { get; }
+
+ public bool? Success { get; }
+
+ public int? LastExitCode { get; }
+
+ public RealmJoinPackageFact WithCompletion(DateTime completedUtc, bool success, int lastExitCode) =>
+ new RealmJoinPackageFact(
+ packageId: PackageId,
+ displayName: DisplayName,
+ version: Version,
+ scope: Scope,
+ startedUtc: StartedUtc,
+ completedUtc: completedUtc,
+ success: success,
+ lastExitCode: lastExitCode);
+ }
+}