From b2d2d37c6f8a2bd2ca07f26e58655378b5851555 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 23 Jul 2026 09:14:30 +0200 Subject: [PATCH] feat(realmjoin): detect aborted RJ ESP (missing 110) and release the completion gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An interactive logon during the RealmJoin first deployment (phase 100/101) makes RJ reclassify the run as a secondary-user deployment, whose branch writes 200/210 and never CompletedFirstDeployment (110) — no crash, no reboot request. The V2 completion gate waited for exactly 110, so these sessions hung until the 60-min hard timeout (session 224b2087; siblings 558bf067/3cc18724 delayed ~50 min the same way). - promote realmjoin_phase_changed to a typed RealmJoinPhaseChanged signal (dual-emitted by the adapter) so RealmJoinFacts.LastDeploymentPhase is persisted across agent restarts - abort rule: prev in {100,101} and current in {200,210} with no outcome yet -> emit realmjoin_first_deployment_incomplete (Warning), cancel the hard-timeout deadline, release the gate with outcome FirstDeploymentIncomplete; also evaluated on the RealmJoinDetected replay path (restart swallows the transition) - deliberately NOT triggered by 101->0 (RJ service restart may still complete the first deployment) nor by a first observation of 210 (session 6f1959c0: RJ already completed before agent boot) - fix realmjoin_timeout always claiming "last phase: 0" - the persisted phase now feeds the message - backend EnrollmentTimeoutClassifier counts the new event as a gate terminal; MCP resource catalog dual-synced - CompleteIfDeferredOrBookkeep widened to a leading-effect list Verified by replaying session 224b2087's signal log: gate releases at 15:05:17 (101->200) instead of hanging until the 15:54 deadline. Co-Authored-By: Claude Fable 5 --- docs/agent/decision-engine.md | 13 +- docs/log.md | 2 + .../RealmJoinWatcherAdapterTests.cs | 10 +- .../SignalAdapters/RealmJoinWatcherAdapter.cs | 19 +- .../RealmJoinGateTests.cs | 193 ++++++++++++++++ .../EnrollmentTimeoutClassifierTests.cs | 4 +- .../Services/EnrollmentTimeoutClassifier.cs | 9 +- .../src/resource-catalog.ts | 1 + .../Engine/DecisionEngine.RealmJoin.cs | 208 ++++++++++++++++-- .../Engine/DecisionEngine.cs | 1 + .../Signals/DecisionSignalKind.cs | 7 + .../State/RealmJoinFacts.cs | 30 ++- .../AutopilotMonitor.Shared/Constants.cs | 5 + .../realmjoin-phase-changed-v1.json | 13 ++ 14 files changed, 485 insertions(+), 30 deletions(-) create mode 100644 tests/fixtures/signal-kinds/realmjoin-phase-changed-v1.json diff --git a/docs/agent/decision-engine.md b/docs/agent/decision-engine.md index 8cbfad74..6be2886f 100644 --- a/docs/agent/decision-engine.md +++ b/docs/agent/decision-engine.md @@ -137,8 +137,17 @@ false` and an arm holds), `EspExiting`, `ImeUserSessionCompleted`, advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`: 1. **Completion gates** (currently only the RealmJoin AND-gate): if RealmJoin was - detected and is neither resolved nor timed-out (hard 60-min timeout), completion is - *deferred* — the session stays in stage and emits `completion_waiting`. + detected and has no terminal outcome yet, completion is *deferred* — the session + stays in stage and emits `completion_waiting`. Three terminals open the gate: + `Resolved` (DeploymentPhase 110), `FirstDeploymentIncomplete` (phase left the + first-deployment window 100/101 for 200/210 without 110 — the RJ ESP was aborted, + typically by an interactive logon reclassifying the run as secondary-user; emits + the `realmjoin_first_deployment_incomplete` Warning, session 224b2087), and + `Timeout` (hard 60-min deadline). The abort rule reads the *persisted* + `RealmJoinFacts.LastDeploymentPhase` (updated by the typed `RealmJoinPhaseChanged` + signal), so it survives agent restarts — the `RealmJoinDetected` replay path + evaluates it too. A first observation of 210 does **not** release (RJ may have + completed before agent boot and deploy afterwards). 2. `Finalizing` stage + ~5 s `FinalizingGrace` deadline. 3. Grace fires → `Completed`, `EnrollmentComplete` outcome, `enrollment_complete` timeline event with full audit payload. diff --git a/docs/log.md b/docs/log.md index 2e1d6846..e53cf14a 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,8 @@ ## 2026-07-23 +* **Update**: `agent/decision-engine.md` — the RealmJoin completion gate has a third terminal, `FirstDeploymentIncomplete`. Session 224b2087 showed why: an interactive logon 17 s after DeploymentPhase 101 makes RealmJoin reclassify the run as a secondary-user deployment, whose branch writes 200/210 and never 110 — the gate stayed closed until the 60-min hard timeout although Hello and desktop were long resolved. `realmjoin_phase_changed` is now a typed `RealmJoinPhaseChanged` signal, so `RealmJoinFacts.LastDeploymentPhase` is persisted across agent restarts (this also fixes every `realmjoin_timeout` event claiming "last phase: 0"); a transition out of 100/101 into 200/210 releases the gate with the `realmjoin_first_deployment_incomplete` Warning, and the `RealmJoinDetected` replay path evaluates the same rule for restarts that swallow the transition. A first observation of 210 deliberately does not release — session 6f1959c0 started at 210 and only deployed afterwards. + * **Creation**: Added `url-registry.md` — all well-known hosts (own subdomains, both blob accounts, API origin, Graph, Entra login) are now defined once per component (`Constants.cs`, web `utils/config.ts`, MCP `src/config.ts`) and referenced everywhere else; three guard tests fail the build on any literal outside the registries. Written because the EU cutover missed two hardcoded copies of the legacy blob host — `LatestVersionsService` (feeds the portal's version badge) and `ValidateBootstrapCodeFunction` — which kept working only through the fail-soft legacy mirror in `build-agent.yml`; those two plus the agent `SelfUpdater` now use the `download.autopilotmonitor.com` alias (verified serving all four artifacts first). The portal's bootstrap allow-list accepts alias AND legacy host during the transition, which imposes a deploy order: web before (or with) backend. The sweep also surfaced a fourth copy of the www/portal host pair in `middleware.ts` (now derived from the registry via `hostRouting.ts`) and a backend short-link built on the bare apex domain, which only worked through a registrar-level 301. ## 2026-07-22 diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs index 3c2c9ace..ef4ba854 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs @@ -151,17 +151,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters } [Fact] - public void TriggerPhaseChangedFromTest_emits_informational_only_no_decision_signal() + public void TriggerPhaseChangedFromTest_emits_typed_signal_and_informational_event() { 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. + // 1) Typed signal — persists LastDeploymentPhase and drives the aborted-RJ-ESP + // detection in the reducer (session 224b2087). Not Detected/Resolved. Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinDetected); Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinResolved); + var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged); + Assert.Equal("RealmJoinWatcher", decision.SourceOrigin); + Assert.Equal("200", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase]); + Assert.Equal("100", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase]); + // 2) Dual-emit informational event keeps the existing timeline entry. var info = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent && p.Payload != null diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs index a8819bac..69d3d109 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs @@ -137,7 +137,24 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters private void OnPhaseChanged(object? sender, RealmJoinPhaseChangedEventArgs e) { - // Phase change is observability-only: no DecisionSignalKind, just a timeline event. + // Dual-emission like OnDetected/OnResolved: (1) the typed signal persists the phase + // into RealmJoinFacts.LastDeploymentPhase (restart-safe) and lets the reducer detect + // the aborted-RJ-ESP shape (phase left 100/101 for 200/210 without 110 — session + // 224b2087); (2) the InformationalEvent keeps the existing timeline entry. + var payload = new Dictionary(StringComparer.Ordinal) + { + [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = e.CurrentPhase.ToString(), + [DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = e.PreviousPhase.ToString(), + }; + _ingress.Post( + kind: DecisionSignalKind.RealmJoinPhaseChanged, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: SourceLabel, + evidence: BuildEvidence( + "realmjoin-phase-changed-v1", + $"RealmJoin DeploymentPhase {e.PreviousPhase} -> {e.CurrentPhase}"), + payload: payload); + // Both raw int (deploymentPhase / previousPhase) and human-readable name // (deploymentPhaseName / previousPhaseName) flow through — int stays for downstream // filters / KQL, name fields make the message + UI rows readable without a lookup. diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs index dbd4a01c..ed393932 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs @@ -241,6 +241,199 @@ namespace AutopilotMonitor.DecisionCore.Tests e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout"); } + // ============================================================== Aborted first deployment (session 224b2087) + + [Fact] + public void PhaseChanged_leaving_first_deployment_for_200_releases_gate_with_warning() + { + // Session 224b2087: interactive logon 17 s after phase 101 → RJ reclassifies the run + // as secondary-user deployment and writes 200/210, never 110. The 101 -> 200 + // transition is impossible in a healthy first deployment, so it releases the gate + // with Outcome=FirstDeploymentIncomplete and a Warning timeline entry. + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101" })).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.RealmJoinPhaseChanged, T0.AddMinutes(10), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200", + [DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "101", + })); + + Assert.Equal(SessionStage.Finalizing, step.NewState.Stage); + Assert.Equal("FirstDeploymentIncomplete", step.NewState.RealmJoinFacts.Outcome!.Value); + Assert.NotNull(step.NewState.RealmJoinFacts.ResolvedUtc); + Assert.Equal(200, step.NewState.RealmJoinFacts.LastDeploymentPhase!.Value); + + // Hard-timeout deadline cancelled 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); + + // Warning timeline entry names both phases. + var warning = Assert.Single(step.Effects, e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry && + e.Parameters != null && + e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_first_deployment_incomplete"); + Assert.Equal("Warning", warning.Parameters!["severity"]); + Assert.Equal("101", warning.Parameters["previousPhase"]); + Assert.Equal("200", warning.Parameters["deploymentPhase"]); + Assert.Contains("110", warning.Parameters["message"]); + } + + [Fact] + public void PhaseChanged_within_first_deployment_window_is_bookkeeping_only() + { + // 100 -> 101 stays inside the first-deployment window — no release, but the phase + // fact must advance (it is the restart-safe witness for the abort rule). + 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.RealmJoinPhaseChanged, T0.AddMinutes(6), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101", + [DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "100", + })); + + Assert.Equal(101, step.NewState.RealmJoinFacts.LastDeploymentPhase!.Value); + Assert.Null(step.NewState.RealmJoinFacts.Outcome); + Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout); + Assert.Empty(step.Effects); + } + + [Fact] + public void PhaseChanged_from_completed_deployment_start_does_not_release() + { + // Session 6f1959c0 protection: RJ already stood at CompletedDeployment (210) when + // the agent booted and only deployed AFTERWARDS (210 -> 200 -> 210). Neither + // transition starts from the first-deployment window, so the abort rule must not + // fire — this ambiguous shape keeps waiting for 110 / the hard timeout (status quo). + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "210" })).NewState; + + state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(6), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200", + [DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "210", + })).NewState; + Assert.Null(state.RealmJoinFacts.Outcome); + + var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(7), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "210", + [DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "200", + })); + + Assert.Null(step.NewState.RealmJoinFacts.Outcome); + Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout); + } + + [Fact] + public void Detected_replay_after_restart_with_deployment_phase_releases_gate() + { + // Agent restarted between the 101 and 200 observations: the watcher re-fires + // Detected with the CURRENT phase (200) and no PhaseChanged ever carries the + // transition. The persisted LastDeploymentPhase=101 is the witness — the Detected + // replay path must release the gate exactly like the PhaseChanged path. + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101" })).NewState; + state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState; + Assert.NotEqual(SessionStage.Finalizing, state.Stage); + Assert.Equal(101, state.RealmJoinFacts.LastDeploymentPhase!.Value); + + var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(12), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200" })); + + Assert.Equal(SessionStage.Finalizing, step.NewState.Stage); + Assert.Equal("FirstDeploymentIncomplete", step.NewState.RealmJoinFacts.Outcome!.Value); + Assert.Contains(step.Effects, e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry && + e.Parameters != null && + e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_first_deployment_incomplete"); + } + + [Fact] + public void Timeout_message_reports_persisted_last_phase_instead_of_zero() + { + // Before RealmJoinPhaseChanged became a typed signal, LastDeploymentPhase was only + // written at detection time — every realmjoin_timeout claimed "last phase: 0" no + // matter how far RJ actually got, hiding this whole failure class from ops. + 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.RealmJoinPhaseChanged, T0.AddMinutes(6), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101", + [DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "100", + })).NewState; + state = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.DesktopArrived, T0.AddMinutes(7))).NewState; + + var step = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.DeadlineFired, T0.AddMinutes(65), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout })); + + var timeout = Assert.Single(step.Effects, e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry && + e.Parameters != null && + e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout"); + Assert.Contains("last phase: 101", timeout.Parameters!["message"]); + Assert.Equal("101", timeout.Parameters["lastSeenPhase"]); + } + + [Fact] + public void SelfDeploying_deferred_terminal_released_by_first_deployment_incomplete() + { + // Same deferred-release contract as RealmJoinResolved: when the SelfDeploying + // terminal was deferred on the closed RJ gate, the aborted-first-deployment release + // must complete the session directly (Completed + enrollment_complete), with the + // Warning entry leading the effects. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("rj-sd-3", "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] = "101" })).NewState; + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })).NewState; + Assert.True(state.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value); + + var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(10), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200", + [DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "101", + })); + + Assert.Equal(SessionStage.Completed, step.NewState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome); + Assert.Equal("FirstDeploymentIncomplete", step.NewState.RealmJoinFacts.Outcome!.Value); + Assert.Contains(step.Effects, e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry && + e.Parameters != null && + e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_first_deployment_incomplete"); + Assert.Contains(step.Effects, e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry && + e.Parameters != null && + e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete"); + } + // ============================================================== SelfDeploying flow [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs index 89eabb98..0f5b11cc 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs @@ -113,9 +113,11 @@ public class EnrollmentTimeoutClassifierTests // Negative Hello terminals leave the agent waiting — must NOT count as resolved. Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("hello_provisioning_failed") }).HelloResolved); Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("hello_completion_timeout") }).HelloResolved); - // Both RealmJoin gate terminals count as resolved (phase 110 or 60-min hard timeout). + // All RealmJoin gate terminals count as resolved (phase 110, aborted first + // deployment — session 224b2087 — or 60-min hard timeout). Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_resolved") }).RealmJoinResolved); Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_timeout") }).RealmJoinResolved); + Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_first_deployment_incomplete") }).RealmJoinResolved); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs b/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs index 069bf82f..92343fcb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs @@ -147,9 +147,12 @@ namespace AutopilotMonitor.Functions.Services helloResolved = true; else if (Eq(type, "realmjoin_detected")) realmJoinDetected = true; - // Either terminal opens the agent-side RealmJoin gate: phase 110 or the 60-min - // hard timeout (both dual-emitted to the timeline by the agent/engine). - else if (Eq(type, "realmjoin_resolved") || Eq(type, "realmjoin_timeout")) + // Any terminal opens the agent-side RealmJoin gate: phase 110, the aborted + // first deployment (phase left 100/101 for 200/210 without 110 — session + // 224b2087) or the 60-min hard timeout (all emitted to the timeline by the + // agent/engine). + else if (Eq(type, "realmjoin_resolved") || Eq(type, "realmjoin_timeout") + || Eq(type, "realmjoin_first_deployment_incomplete")) realmJoinResolved = true; // Policy facts for the Hello-disabled / User-ESP-skipped completion mirror. // Contradicting observations are resolved pessimistically below (both-seen → diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index ab988117..e9efac94 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -115,6 +115,7 @@ export const EVENT_TYPES_CATALOG = { 'realmjoin_phase_changed', 'realmjoin_resolved', 'realmjoin_timeout', + 'realmjoin_first_deployment_incomplete', 'realmjoin_package_started', 'realmjoin_package_completed', ], diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs index 38958543..935edc62 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs @@ -9,11 +9,12 @@ using SharedConstants = AutopilotMonitor.Shared.Constants; 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 + // The agent posts six 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. + // and gates the enrollment-completion AND-gate. Resolution (phase 110), the + // aborted-first-deployment release (phase left 100/101 for 200/210 without 110 — + // session 224b2087) or the 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 — @@ -25,6 +26,10 @@ namespace AutopilotMonitor.DecisionCore.Engine public static class RealmJoinPayloadKeys { public const string DeploymentPhase = "deploymentPhase"; + // Set on RealmJoinPhaseChanged: the phase the watcher observed immediately before + // the transition. The reducer prefers the PERSISTED RealmJoinFacts.LastDeploymentPhase + // (restart-safe) and only falls back to this process-local value. + public const string PreviousPhase = "previousPhase"; public const string PackageId = "packageId"; public const string DisplayName = "displayName"; public const string Version = "version"; @@ -107,6 +112,25 @@ namespace AutopilotMonitor.DecisionCore.Engine } builder.RealmJoinFacts = updatedFacts; + // Replay path (agent restarted between the 101 and 200 observations): the watcher + // re-fires Detected with the CURRENT phase and no PhaseChanged ever carries the + // transition — the persisted LastDeploymentPhase is the only witness of the aborted + // first deployment. Evaluate the abort rule here too so the gate does not stay + // closed until the hard timeout. + if (alreadyDetected + && phase.HasValue + && IsFirstDeploymentAbortTransition(state.RealmJoinFacts, state.RealmJoinFacts.LastDeploymentPhase?.Value, phase.Value)) + { + return ReleaseFirstDeploymentIncomplete( + state: state, + signal: signal, + builder: builder, + nextStep: nextStep, + previousPhase: state.RealmJoinFacts.LastDeploymentPhase!.Value, + currentPhase: phase.Value, + triggerBase: nameof(DecisionSignalKind.RealmJoinDetected)); + } + var effects = Array.Empty(); if (!alreadyDetected) { @@ -177,7 +201,7 @@ namespace AutopilotMonitor.DecisionCore.Engine preparedBuilder: builder, nextStepIndex: nextStep, trigger: nameof(DecisionSignalKind.RealmJoinResolved), - leadingEffect: cancelEffect); + leadingEffects: cancelEffect != null ? new[] { cancelEffect } : null); } /// @@ -237,7 +261,156 @@ namespace AutopilotMonitor.DecisionCore.Engine preparedBuilder: builder, nextStepIndex: nextStep, trigger: $"DeadlineFired:{DeadlineNames.RealmJoinTimeout}", - leadingEffect: timeoutEffect); + leadingEffects: new[] { timeoutEffect }); + } + + // RJ DeploymentPhase enum values relevant to the abort rule (mirrors + // RealmJoin.Core.SoftwarePackaging.DeploymentPhase; the agent-side RealmJoinInfo + // constants are not referencable from this netstandard shared lib). + private const int RjPhaseRunningFirstDeployment = 100; + private const int RjPhaseRunningFirstDeploymentAuto = 101; + private const int RjPhaseCompletedFirstDeployment = 110; + private const int RjPhaseRunningDeployment = 200; + private const int RjPhaseCompletedDeployment = 210; + + private static bool IsRealmJoinFirstDeploymentPhase(int phase) => + phase == RjPhaseRunningFirstDeployment || phase == RjPhaseRunningFirstDeploymentAuto; + + /// + /// The aborted-RJ-ESP shape (session 224b2087): the phase left the first-deployment + /// window (100/101) for a regular deployment phase (200/210) — a transition that cannot + /// occur in a healthy first deployment, whose only exit is CompletedFirstDeployment + /// (110). Root cause observed in the RJ logs: an interactive logon lands seconds after + /// 101, RJ reclassifies the run as a secondary-user deployment + /// (isFirstMachineDeployment=False) and that branch writes 200/210, never 110. + /// + /// Deliberately NOT triggered by 101 → 0 (RJ service restart may still complete the + /// first deployment) nor by a first observation of 210 (RJ already completed before + /// the agent booted — session 6f1959c0 starts 210 → 200; releasing there would be + /// wrong). comes from the persisted + /// where available so the rule + /// survives an agent restart between the 101 and 200 observations. + /// + /// + private static bool IsFirstDeploymentAbortTransition(RealmJoinFacts facts, int? previousPhase, int currentPhase) => + facts.DetectedUtc != null + && facts.Outcome == null + && previousPhase.HasValue + && IsRealmJoinFirstDeploymentPhase(previousPhase.Value) + && (currentPhase == RjPhaseRunningDeployment || currentPhase == RjPhaseCompletedDeployment); + + /// + /// Phase-transition observation from the watcher. Always persists the current phase + /// into (restart-safe; also fixes the + /// realmjoin_timeout event reporting "last phase: 0" regardless of how far RJ + /// actually got). When matches, emits + /// the realmjoin_first_deployment_incomplete Warning, cancels the hard-timeout + /// deadline and releases the completion gate — for us RJ is finished at that point. + /// + private DecisionStep HandleRealmJoinPhaseChangedV1(DecisionState state, DecisionSignal signal) + { + var currentPhase = TryReadPhase(signal); + if (currentPhase == null) + { + var deadEnd = BumpStepBookkeeping(state, signal); + var deadEndTransition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: deadEnd.StepIndex, + trigger: nameof(DecisionSignalKind.RealmJoinPhaseChanged), + deadEndReason: "realmjoin_phase_changed_missing_phase"); + return new DecisionStep(deadEnd, deadEndTransition, Array.Empty()); + } + + // Persisted fact first (survives agent restarts), watcher's process-local value + // as fallback for the very first phase observation of a session. + var previousPhase = state.RealmJoinFacts.LastDeploymentPhase?.Value + ?? TryReadInt(signal, RealmJoinPayloadKeys.PreviousPhase); + + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + builder.RealmJoinFacts = state.RealmJoinFacts.WithLastPhase(currentPhase.Value, signal.SessionSignalOrdinal); + + if (!IsFirstDeploymentAbortTransition(state.RealmJoinFacts, previousPhase, currentPhase.Value)) + { + var bookkept = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.RealmJoinPhaseChanged)); + return new DecisionStep(bookkept, transition, Array.Empty()); + } + + return ReleaseFirstDeploymentIncomplete( + state: state, + signal: signal, + builder: builder, + nextStep: nextStep, + previousPhase: previousPhase!.Value, + currentPhase: currentPhase.Value, + triggerBase: nameof(DecisionSignalKind.RealmJoinPhaseChanged)); + } + + /// + /// Shared gate-release for the aborted-first-deployment rule — called from + /// and from the + /// replay path (agent restarted between the + /// 101 and 200 observations: the watcher re-fires Detected with the CURRENT phase and + /// no PhaseChanged ever carries the transition, so the persisted fact is the only + /// witness). Mirrors 's release shape with a + /// Warning timeline entry ahead of the completion effects. + /// + private DecisionStep ReleaseFirstDeploymentIncomplete( + DecisionState state, + DecisionSignal signal, + DecisionStateBuilder builder, + int nextStep, + int previousPhase, + int currentPhase, + string triggerBase) + { + builder.CancelDeadline(DeadlineNames.RealmJoinTimeout); + builder.RealmJoinFacts = builder.RealmJoinFacts.WithFirstDeploymentIncomplete( + signal.OccurredAtUtc, currentPhase, signal.SessionSignalOrdinal); + + var leadingEffects = new List(capacity: 2); + var cancelEffect = BuildRealmJoinTimeoutCancelEffectIfArmed(state); + if (cancelEffect != null) leadingEffects.Add(cancelEffect); + leadingEffects.Add(BuildRealmJoinFirstDeploymentIncompleteEvent(previousPhase, currentPhase)); + + return CompleteIfDeferredOrBookkeep( + state: state, + signal: signal, + preparedBuilder: builder, + nextStepIndex: nextStep, + trigger: triggerBase + ":FirstDeploymentIncomplete", + leadingEffects: leadingEffects); + } + + /// + /// Build the realmjoin_first_deployment_incomplete Warning timeline entry. + /// Reducer-owned (like ) — the agent adapter + /// only dual-emits the neutral realmjoin_phase_changed observation; the abort + /// interpretation lives here where the persisted facts are. + /// + private static DecisionEffect BuildRealmJoinFirstDeploymentIncompleteEvent(int previousPhase, int currentPhase) + { + return new DecisionEffect( + kind: DecisionEffectKind.EmitEventTimelineEntry, + parameters: new Dictionary(StringComparer.Ordinal) + { + [SignalPayloadKeys.EventType] = SharedConstants.EventTypes.RealmJoinFirstDeploymentIncomplete, + [SignalPayloadKeys.Source] = "DecisionEngine", + [SignalPayloadKeys.Severity] = "Warning", + [SignalPayloadKeys.Message] = + $"RealmJoin left first deployment (phase {previousPhase} -> {currentPhase}) without CompletedFirstDeployment (110) — the RealmJoin ESP likely aborted. Treating the RealmJoin deployment as finished.", + ["previousPhase"] = previousPhase.ToString(CultureInfo.InvariantCulture), + ["deploymentPhase"] = currentPhase.ToString(CultureInfo.InvariantCulture), + }); } /// @@ -360,7 +533,7 @@ namespace AutopilotMonitor.DecisionCore.Engine DecisionStateBuilder preparedBuilder, int nextStepIndex, string trigger, - DecisionEffect? leadingEffect) + IReadOnlyList? leadingEffects) { var selfDeployingDeferred = state.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value == true; var classicReady = state.HelloResolvedUtc != null && state.DesktopArrivedUtc != null; @@ -421,8 +594,8 @@ namespace AutopilotMonitor.DecisionCore.Engine // Plan v9 Phase 4 — UI phase coverage: emit FinalizingSetup + Complete phase // declarations BEFORE enrollment_complete so the Web timeline opens both bars // for RJ-deferred-completion just like the direct SelfDeploying-terminal path. - var effects = new List(capacity: 4); - if (leadingEffect != null) effects.Add(leadingEffect); + var effects = new List(capacity: (leadingEffects?.Count ?? 0) + 3); + if (leadingEffects != null && leadingEffects.Count > 0) effects.AddRange(leadingEffects); effects.Add(BuildPhaseTransitionEffect(EnrollmentPhase.FinalizingSetup, completedState, trigger + ":SelfDeployingDeferred")); effects.Add(BuildPhaseTransitionEffect(EnrollmentPhase.Complete, completedState, trigger + ":SelfDeployingDeferred")); effects.Add(BuildEnrollmentCompleteEffect(completedState, trigger + ":SelfDeployingDeferred")); @@ -434,19 +607,19 @@ namespace AutopilotMonitor.DecisionCore.Engine if (classicReady) { // This IS the gate-release path: the RealmJoin gate has just opened (the caller - // wrote WithResolved / WithTimeoutOutcome into preparedBuilder), so complete - // directly. Note the completion gates read `state` (pre-resolution), where RJ is - // still closed — routing this through CompleteThroughFinalizingOrDefer would - // re-defer on the very gate we just released. A future second gate that must - // re-block here would re-check the *post* state (WDP-v2 follow-up, ARCH-F1). - var extra = leadingEffect != null ? new[] { leadingEffect } : null; + // wrote WithResolved / WithTimeoutOutcome / WithFirstDeploymentIncomplete into + // preparedBuilder), so complete directly. Note the completion gates read `state` + // (pre-resolution), where RJ is still closed — routing this through + // CompleteThroughFinalizingOrDefer would re-defer on the very gate we just + // released. A future second gate that must re-block here would re-check the + // *post* state (WDP-v2 follow-up, ARCH-F1). return TransitionToFinalizing( state: state, signal: signal, preparedBuilder: preparedBuilder, nextStepIndex: nextStepIndex, trigger: trigger, - extraLeadingEffects: extra); + extraLeadingEffects: leadingEffects); } // Bookkeeping only — defer to the next Hello / Desktop / SelfDeploying signal. @@ -458,10 +631,7 @@ namespace AutopilotMonitor.DecisionCore.Engine nextStepIndex: nextStepIndex, trigger: trigger); - var bookkeepEffects = leadingEffect != null - ? new[] { leadingEffect } - : Array.Empty(); - return new DecisionStep(newState, transition, bookkeepEffects); + return new DecisionStep(newState, transition, MaterializeEffects(leadingEffects)); } /// diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs index 60bd6a8a..2a96bb78 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -143,6 +143,7 @@ namespace AutopilotMonitor.DecisionCore.Engine // ----- RealmJoin deployment tracking (DecisionEngine.RealmJoin.cs) ----- (DecisionSignalKind.RealmJoinDetected, 1) => HandleRealmJoinDetectedV1(state, signal), (DecisionSignalKind.RealmJoinResolved, 1) => HandleRealmJoinResolvedV1(state, signal), + (DecisionSignalKind.RealmJoinPhaseChanged, 1) => HandleRealmJoinPhaseChangedV1(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) — diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs index f2e03732..a87ba5c1 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs @@ -118,5 +118,12 @@ namespace AutopilotMonitor.DecisionCore.Signals RealmJoinTimeout, RealmJoinPackageStarted, RealmJoinPackageCompleted, + // Session 224b2087 (2026-07-22): DeploymentPhase transition observed by the watcher. + // Promoted from InformationalEvent-only so RealmJoinFacts.LastDeploymentPhase is + // persisted across agent restarts AND so the reducer can detect the aborted-RJ-ESP + // shape: an interactive logon during the first deployment (100/101) makes RJ reclassify + // the run as a secondary-user deployment, which writes 200/210 and NEVER 110 — the + // gate would otherwise stay closed until the 60-min hard timeout. + RealmJoinPhaseChanged, } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs index 26515ca8..d472868b 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs @@ -14,8 +14,8 @@ namespace AutopilotMonitor.DecisionCore.State /// /// 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. + /// is set once on observing phase 110 (or the aborted-first-deployment release); later phase readings update only. + /// is one of "Resolved" / "Timeout" / "FirstDeploymentIncomplete"; null while RJ is still running. /// /// public sealed class RealmJoinFacts @@ -25,6 +25,11 @@ namespace AutopilotMonitor.DecisionCore.State public const string OutcomeResolved = "Resolved"; public const string OutcomeTimeout = "Timeout"; + // Session 224b2087 (2026-07-22): the RJ ESP was aborted (interactive logon during the + // first deployment reclassified the run as secondary-user) — DeploymentPhase left + // 100/101 for 200/210 without ever writing CompletedFirstDeployment (110). The gate + // releases with this distinct outcome so reporting can tell the three cases apart. + public const string OutcomeFirstDeploymentIncomplete = "FirstDeploymentIncomplete"; public static readonly RealmJoinFacts Empty = new RealmJoinFacts( detectedUtc: null, @@ -161,6 +166,27 @@ namespace AutopilotMonitor.DecisionCore.State packages: Packages); } + /// + /// Terminal outcome for the aborted-RJ-ESP case: DeploymentPhase left the + /// first-deployment window (100/101) for a regular deployment phase (200/210) without + /// ever reaching CompletedFirstDeployment (110). Sets (this + /// IS our resolution moment — RJ counts as finished for the completion gate) with + /// . No-op when an outcome is already set. + /// + public RealmJoinFacts WithFirstDeploymentIncomplete(DateTime utc, int phase, long sourceSignalOrdinal) + { + if (Outcome != null) return this; // resolved/timeout already wins + return new RealmJoinFacts( + detectedUtc: DetectedUtc, + resolvedUtc: new SignalFact(utc, sourceSignalOrdinal), + lastDeploymentPhase: new SignalFact(phase, sourceSignalOrdinal), + outcome: new SignalFact(OutcomeFirstDeploymentIncomplete, sourceSignalOrdinal), + selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + productVersion: ProductVersion, + releaseChannel: ReleaseChannel, + packages: Packages); + } + public RealmJoinFacts WithLastPhase(int phase, long sourceSignalOrdinal) { if (LastDeploymentPhase != null && LastDeploymentPhase.Value == phase) return this; diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 1ead4e72..84474b66 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -463,6 +463,11 @@ namespace AutopilotMonitor.Shared public const string RealmJoinPhaseChanged = "realmjoin_phase_changed"; public const string RealmJoinResolved = "realmjoin_resolved"; public const string RealmJoinTimeout = "realmjoin_timeout"; + // Warning: DeploymentPhase left the first-deployment window (100/101) for 200/210 + // without CompletedFirstDeployment (110) — the RJ ESP was aborted (typically an + // interactive logon during the first deployment reclassified the run as + // secondary-user). The completion gate releases at this point. + public const string RealmJoinFirstDeploymentIncomplete = "realmjoin_first_deployment_incomplete"; public const string RealmJoinPackageStarted = "realmjoin_package_started"; public const string RealmJoinPackageCompleted = "realmjoin_package_completed"; diff --git a/tests/fixtures/signal-kinds/realmjoin-phase-changed-v1.json b/tests/fixtures/signal-kinds/realmjoin-phase-changed-v1.json new file mode 100644 index 00000000..8393586f --- /dev/null +++ b/tests/fixtures/signal-kinds/realmjoin-phase-changed-v1.json @@ -0,0 +1,13 @@ +# RealmJoinPhaseChanged v1 — RealmJoin DeploymentPhase transition. +# Posted by RealmJoinWatcherAdapter on every observed DeploymentPhase change (dual-emitted +# alongside the existing realmjoin_phase_changed InformationalEvent). Payload carries the +# current and previous phase DWORDs. +# Reducer: HandleRealmJoinPhaseChangedV1 → always updates RealmJoinFacts.LastDeploymentPhase +# (restart-safe persistence; fixes realmjoin_timeout reporting "last phase: 0"). When the +# phase leaves the first-deployment window (100/101) for a regular deployment phase (200/210) +# without CompletedFirstDeployment (110) ever being observed, the RJ ESP was aborted (e.g. +# interactive logon during first deployment reclassified the run as secondary-user): emits +# realmjoin_first_deployment_incomplete (Warning), cancels the RealmJoinTimeout deadline and +# releases the completion gate via CompleteIfDeferredOrBookkeep with +# Outcome="FirstDeploymentIncomplete". +{"SessionSignalOrdinal":43,"SessionTraceOrdinal":43,"Kind":"RealmJoinPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T10:26:00.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-phase-changed-v1","Summary":"RealmJoin DeploymentPhase 101 -> 200","DerivationInputs":{"registryKey":"HKLM\\SYSTEM\\CurrentControlSet\\Services\\realmjoin\\Parameters"}},"Payload":{"deploymentPhase":"200","previousPhase":"101"}}