diff --git a/docs/agent/decision-engine.md b/docs/agent/decision-engine.md
index 09cdc18c..caa5f49a 100644
--- a/docs/agent/decision-engine.md
+++ b/docs/agent/decision-engine.md
@@ -8,7 +8,7 @@ tags:
- decision-engine
- reducer
- completion
-timestamp: 2026-07-11T00:00:00+02:00
+timestamp: 2026-08-30T00:00:00+02:00
---
# V2 Agent — Decision Engine (DecisionCore)
@@ -175,6 +175,20 @@ advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
scheduler contract) dead-ends as `realmjoin_timeout_stale_superseded_by_rearm`.
The idle case (detected, no deployment activity ever) times out at 60 min
unchanged, and phase 200/210 activity never extends.
+ **Self-update observation** (gktatooine session 946ccbd6): the version on
+ `RealmJoinDetected` is read once from `RealmJoin.exe`, but the tray's `AppUpdater`
+ often replaces the MSI-shipped binary on its first start — minutes after detection,
+ before the deployment begins. `RealmJoinWatcher` therefore arms a
+ `RegNotifyChangeKeyValue` watcher on `HKLM\...\Uninstall\RealmJoin` (the only
+ registry write of the updater; appearance-watch on the `Uninstall` parent when the
+ entry does not exist yet) once Detected fired, re-reads the binary version after a
+ 2 s debounce (registry `DisplayVersion` as fallback while the file is locked) and
+ posts the typed `RealmJoinAutoUpdateDetected` signal plus the
+ `realmjoin_autoupdate_detected` timeline event when the version differs from the
+ baseline. The reducer overrides `RealmJoinFacts.ProductVersion` / `ReleaseChannel`
+ (`WithVersionOverride` — the one legitimate later write past the set-once helpers);
+ it never touches the gate, the deadline, or `LastActivityUtc`. An unreadable
+ baseline at detection is seeded silently by the first successful re-read.
A closed gate also outranks the 30-minute `AdvisoryCompletion` backstop: that
handler re-arms (trigger suffix `:CompletionGateHolding`) instead of failing while
the RealmJoin gate is closed *and* its own deadline is still armed, because the gate
diff --git a/docs/log.md b/docs/log.md
index ccdaa337..3357a885 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -1,5 +1,9 @@
# Log
+## 2026-08-30 (13)
+
+* **Update**: `agent/decision-engine.md` — RealmJoin self-update observation. `realmjoin_detected` reported the MSI-shipped build (4.21.4) although the tray auto-updated to 4.21.18 nine minutes later and ran the whole deployment on it (gktatooine session 946ccbd6). `RealmJoinWatcher` now watches `HKLM\...\Uninstall\RealmJoin` (the updater's only registry write) after detection, re-reads the binary version on change and emits `realmjoin_autoupdate_detected` (`previousVersion`, `newVersion`, `releaseChannel`); the typed `RealmJoinAutoUpdateDetected` signal overrides `RealmJoinFacts.ProductVersion`/`ReleaseChannel` and nothing else. Portal device card shows the updated version with an "auto-updated from" note.
+
## 2026-08-30 (12)
* **Update**: `web/portal-navigation-prefetch.md` — hosting citation now points at `deploy-web.yml`: the portal is built in CI (setup-node 22, `npm ci`, `next build`) and uploaded with the pinned SWA CLI; the Azure/static-web-apps-deploy action, its Oryx in-container build and the per-PR preview environments are gone. All non-GitHub-owned actions are SHA-pinned; the release and rules auto-commit steps use first-party `gh`/`git`.
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 ef4ba854..725f3d13 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs
@@ -600,5 +600,138 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.DoesNotContain(ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPackageCompleted);
}
+
+ // ---- auto-update (Uninstall\RealmJoin) -----------------------------------------------
+
+ private sealed class VersionSource
+ {
+ public RealmJoinVersionInfo Info = RealmJoinVersionInfo.Empty;
+ public string? DisplayVersion;
+ public RealmJoinVersionInfo Read() => Info;
+ public string? ReadDisplayVersion() => DisplayVersion;
+ }
+
+ private sealed class AutoUpdateHarness : IDisposable
+ {
+ private readonly TempDirectory _tmp = new TempDirectory();
+ public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
+ public VersionSource Versions { get; } = new VersionSource();
+ public RealmJoinWatcher Watcher { get; }
+ public RealmJoinWatcherAdapter Adapter { get; }
+
+ public AutoUpdateHarness()
+ {
+ var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
+ var registry = new FakeRealmJoinRegistry();
+ Watcher = new RealmJoinWatcher(
+ logger,
+ enumeratePackageIds: registry.Enumerate,
+ tryReadPackage: registry.TryRead,
+ keyExists: registry.KeyExists,
+ readVersionInfo: Versions.Read,
+ readUninstallDisplayVersion: Versions.ReadDisplayVersion);
+ Adapter = new RealmJoinWatcherAdapter(Watcher, Ingress, new VirtualClock(Fixed));
+ }
+
+ public void Dispose()
+ {
+ Adapter.Dispose();
+ Watcher.Dispose();
+ _tmp.Dispose();
+ }
+ }
+
+ [Fact]
+ public void AutoUpdate_fires_once_with_previous_and_new_version_when_binary_version_changes_after_detection()
+ {
+ // gktatooine session 946ccbd6: realmjoin_detected read 4.21.4 from the MSI-shipped
+ // binary; nine minutes later the tray AppUpdater swapped it for 4.21.18 and rewrote
+ // Uninstall\RealmJoin. The Detected read is the baseline; the re-read after the
+ // Uninstall-key change must surface exactly one auto-update event.
+ using var h = new AutoUpdateHarness();
+ h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "canary");
+ h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
+ Assert.Equal("4.21.4", h.Watcher.BaselineVersionForTest);
+
+ h.Versions.Info = new RealmJoinVersionInfo("4.21.18", "canary");
+ h.Watcher.TriggerCheckAutoUpdateFromTest();
+
+ var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
+ Assert.Equal("4.21.4", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousVersion]);
+ Assert.Equal("4.21.18", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ProductVersion]);
+ Assert.Equal("canary", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel]);
+
+ var info = Assert.Single(h.Ingress.Posted, p =>
+ p.Kind == DecisionSignalKind.InformationalEvent
+ && p.Payload != null
+ && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
+ && et == SharedEventTypes.RealmJoinAutoUpdateDetected);
+ Assert.Equal("4.21.4", info.Payload!["previousVersion"]);
+ Assert.Equal("4.21.18", info.Payload["newVersion"]);
+ Assert.Equal("canary", info.Payload["releaseChannel"]);
+ Assert.Contains("4.21.4 -> 4.21.18", info.Payload[SignalPayloadKeys.Message]);
+
+ // Same version again (updater rewrote more values) — no duplicate.
+ h.Watcher.TriggerCheckAutoUpdateFromTest();
+ Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
+ Assert.Equal("4.21.18", h.Watcher.BaselineVersionForTest);
+ }
+
+ [Fact]
+ public void AutoUpdate_stays_silent_when_version_is_unchanged_or_unreadable()
+ {
+ using var h = new AutoUpdateHarness();
+ h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "release");
+ h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
+
+ // Unchanged (e.g. the tray creating the Uninstall entry on first start).
+ h.Watcher.TriggerCheckAutoUpdateFromTest();
+ // Unreadable (binary locked mid-swap, registry value not there yet) — baseline kept.
+ h.Versions.Info = RealmJoinVersionInfo.Empty;
+ h.Versions.DisplayVersion = null;
+ h.Watcher.TriggerCheckAutoUpdateFromTest();
+
+ Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
+ Assert.Equal("4.21.4", h.Watcher.BaselineVersionForTest);
+ }
+
+ [Fact]
+ public void AutoUpdate_falls_back_to_Uninstall_DisplayVersion_and_keeps_baseline_channel()
+ {
+ using var h = new AutoUpdateHarness();
+ h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "canary");
+ h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
+
+ h.Versions.Info = RealmJoinVersionInfo.Empty; // RealmJoin.exe still held by the updater
+ h.Versions.DisplayVersion = "4.21.18"; // registry already rewritten
+ h.Watcher.TriggerCheckAutoUpdateFromTest();
+
+ var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
+ Assert.Equal("4.21.18", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.ProductVersion]);
+ // DisplayVersion carries no channel tag — the Detected channel is the best evidence.
+ Assert.Equal("canary", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel]);
+ }
+
+ [Fact]
+ public void AutoUpdate_seeds_baseline_silently_when_Detected_could_not_read_the_binary()
+ {
+ using var h = new AutoUpdateHarness();
+ h.Versions.Info = RealmJoinVersionInfo.Empty;
+ h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
+ Assert.Null(h.Watcher.BaselineVersionForTest);
+
+ // First successful read has nothing to compare against — no phantom update.
+ h.Versions.Info = new RealmJoinVersionInfo("4.21.18", "canary");
+ h.Watcher.TriggerCheckAutoUpdateFromTest();
+ Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
+ Assert.Equal("4.21.18", h.Watcher.BaselineVersionForTest);
+
+ // A later genuine change is still reported against the seeded baseline.
+ h.Versions.Info = new RealmJoinVersionInfo("4.21.19", "canary");
+ h.Watcher.TriggerCheckAutoUpdateFromTest();
+ var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
+ Assert.Equal("4.21.18", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousVersion]);
+ }
+
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs
index 5bd5350f..9d536f55 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs
@@ -102,6 +102,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// Defensive fallback for legacy 32-bit installs.
public const string RealmJoinExePathX86 = @"C:\Program Files (x86)\RealmJoin\RealmJoin.exe";
+ ///
+ /// Always-existing parent of the RJ Add/Remove-Programs entry — the appearance-watch
+ /// anchor when does not exist yet (the tray creates
+ /// the entry on its first start, after the service key is already present).
+ ///
+ public const string UninstallRootPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
+
+ /// Sub-key name of the RJ entry under .
+ public const string UninstallRealmJoinKeyName = "RealmJoin";
+
+ ///
+ /// RJ's Add/Remove-Programs entry. The in-app updater (RealmJoin.Core.AppUpdater)
+ /// rewrites this key (DisplayVersion, InstallDate) in the same second it
+ /// swaps RealmJoin.exe — the only registry write that reflects an RJ self-update.
+ /// Neither SOFTWARE\RealmJoin nor Services\realmjoin\Parameters carry a version.
+ ///
+ public const string UninstallRealmJoinPath = UninstallRootPath + @"\" + UninstallRealmJoinKeyName;
+
+ /// Bare version string under , e.g. 4.21.18 (no channel tag).
+ public const string DisplayVersionValueName = "DisplayVersion";
+
+ ///
+ /// Best-effort read of DisplayVersion under .
+ /// Fallback for the auto-update watcher when RealmJoin.exe is momentarily locked by
+ /// the updater; carries no release channel. null when absent or unreadable.
+ ///
+ public static string? TryReadUninstallDisplayVersion()
+ {
+ try
+ {
+ using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
+ using (var key = baseKey.OpenSubKey(UninstallRealmJoinPath, writable: false))
+ {
+ var raw = key?.GetValue(DisplayVersionValueName) as string;
+ return string.IsNullOrWhiteSpace(raw) ? null : raw!.Trim();
+ }
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
///
/// Release-channel name reported when the version string carries no SemVer prerelease
/// tag. Per the RJ developer, only beta/canary builds are tagged — untagged == stable.
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs
index fce339c8..1771e3ad 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs
@@ -35,6 +35,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
+ ///
+ /// RJ replaced its own binary after detection (tray AppUpdater "New version found …,
+ /// update required" on first start). is the version reported
+ /// on (or the last auto-update),
+ /// the version now installed. Observability-only — the deployment lifecycle is unaffected.
+ ///
+ public sealed class RealmJoinAutoUpdateDetectedEventArgs : EventArgs
+ {
+ public string PreviousVersion { get; }
+ public string NewVersion { get; }
+ /// Channel of the new build; null when only the registry fallback was readable.
+ public string? ReleaseChannel { get; }
+
+ public RealmJoinAutoUpdateDetectedEventArgs(string previousVersion, string newVersion, string? releaseChannel)
+ {
+ PreviousVersion = previousVersion;
+ NewVersion = newVersion;
+ ReleaseChannel = releaseChannel;
+ }
+ }
+
public sealed class RealmJoinResolvedEventArgs : EventArgs
{
public int DeploymentPhase { get; }
@@ -104,13 +125,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
internal delegate IReadOnlyList EnumeratePackageIdsDelegate(RegistryHive hive, string packagesPath);
internal delegate bool TryReadPackageDelegate(RegistryHive hive, string packagesPath, string packageId, out RealmJoinPackageSnapshot snapshot);
internal delegate bool KeyExistsDelegate(RegistryHive hive, string subPath);
+ internal delegate RealmJoinVersionInfo ReadVersionInfoDelegate();
+ internal delegate string? ReadUninstallDisplayVersionDelegate();
+
+ ///
+ /// Debounce for the Uninstall-key watcher. Longer than :
+ /// the updater rewrites several values and swaps RealmJoin.exe in the same window,
+ /// so the version re-read should land after the binary is back in place.
+ ///
+ internal const int AutoUpdateDebounceMilliseconds = 2000;
private readonly AgentLogger _logger;
private readonly EnumeratePackageIdsDelegate _enumeratePackageIds;
private readonly TryReadPackageDelegate _tryReadPackage;
private readonly KeyExistsDelegate _keyExists;
+ private readonly ReadVersionInfoDelegate _readVersionInfo;
+ private readonly ReadUninstallDisplayVersionDelegate _readUninstallDisplayVersion;
private readonly object _stateLock = new object();
+ // Auto-update stage (armed once Detected fired, held until Stop).
+ private RegistryWatcher? _uninstallAppearanceWatcher;
+ private RegistryWatcher? _uninstallWatcher;
+ private Timer? _uninstallDebounce;
+ // Version baseline the next Uninstall-key change is compared against. Seeded from the
+ // Detected read; null when the binary was unreadable then — the first successful
+ // re-read seeds it silently instead of reporting a phantom update.
+ private string? _baselineVersion;
+ private string? _baselineChannel;
+
// Stage-1 parent-watchers (disposed on appearance).
private RegistryWatcher? _servicesAppearanceWatcher;
private RegistryWatcher? _machineSoftwareAppearanceWatcher;
@@ -149,6 +191,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
public event EventHandler? RealmJoinPhaseChanged;
public event EventHandler? RealmJoinPackageStarted;
public event EventHandler? RealmJoinPackageCompleted;
+ public event EventHandler? RealmJoinAutoUpdateDetected;
public RealmJoinWatcher(AgentLogger logger)
: this(logger, RealmJoinInfo.EnumeratePackageIds, RealmJoinInfo.TryReadPackage, KeyExistsViaRegistry)
@@ -159,12 +202,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
AgentLogger logger,
EnumeratePackageIdsDelegate enumeratePackageIds,
TryReadPackageDelegate tryReadPackage,
- KeyExistsDelegate keyExists)
+ KeyExistsDelegate keyExists,
+ ReadVersionInfoDelegate? readVersionInfo = null,
+ ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_enumeratePackageIds = enumeratePackageIds ?? throw new ArgumentNullException(nameof(enumeratePackageIds));
_tryReadPackage = tryReadPackage ?? throw new ArgumentNullException(nameof(tryReadPackage));
_keyExists = keyExists ?? throw new ArgumentNullException(nameof(keyExists));
+ _readVersionInfo = readVersionInfo ?? RealmJoinInfo.TryReadRealmJoinVersionInfo;
+ _readUninstallDisplayVersion = readUninstallDisplayVersion ?? RealmJoinInfo.TryReadUninstallDisplayVersion;
}
public void Start()
@@ -224,14 +271,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
DisposeAndNull(ref _servicesAppearanceWatcher);
DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
DisposeAndNull(ref _userSoftwareAppearanceWatcher);
+ DisposeAndNull(ref _uninstallAppearanceWatcher);
_serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null;
_machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null;
_userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null;
+ _uninstallDebounce?.Dispose(); _uninstallDebounce = null;
DisposeAndNull(ref _serviceRealmjoinWatcher);
DisposeAndNull(ref _machineRealmJoinWatcher);
DisposeAndNull(ref _userRealmJoinWatcher);
+ DisposeAndNull(ref _uninstallWatcher);
}
_logger.Info($"RealmJoinWatcher: stopped ({reason})");
}
@@ -555,13 +605,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (fireDetected)
{
var initial = phase ?? 0;
- // Read RJ version + release channel best-effort (fail-soft to null) ONLY when
- // the Detected event actually fires — single read per session lifetime keeps
- // any potential file-system cost to a single hit.
- var versionInfo = RealmJoinInfo.TryReadRealmJoinVersionInfo();
+ // Read RJ version + release channel best-effort (fail-soft to null) when the
+ // Detected event fires. The result seeds the auto-update baseline; later reads
+ // only happen when the Uninstall key changes (see CheckAutoUpdate).
+ var versionInfo = _readVersionInfo();
+ lock (_stateLock)
+ {
+ _baselineVersion = versionInfo.ProductVersion;
+ _baselineChannel = versionInfo.ReleaseChannel;
+ }
_logger.Info($"RealmJoinWatcher: RJ detected (phase={initial}, productVersion={versionInfo.ProductVersion ?? ""}, releaseChannel={versionInfo.ReleaseChannel ?? ""})");
try { RealmJoinDetected?.Invoke(this, new RealmJoinDetectedEventArgs(initial, versionInfo.ProductVersion, versionInfo.ReleaseChannel)); }
catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinDetected handler threw", ex); }
+
+ EnsureUninstallStage();
}
if (firePhaseChange && prevPhase.HasValue && phase.HasValue)
@@ -593,6 +650,115 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
+ // ---- auto-update stage (Uninstall\RealmJoin) ---------------------------------------
+
+ ///
+ /// Arm the RJ self-update observer. The tray's AppUpdater rewrites
+ /// whenever it swaps the binary — on a
+ /// first start with a stale MSI payload this happens minutes after Detected, so the
+ /// version reported on would otherwise be wrong for the
+ /// rest of the session. Same appearance-then-subtree pattern as the other stages; the
+ /// entry is created by the tray on first start, i.e. it may not exist at Detected time.
+ ///
+ private void EnsureUninstallStage()
+ {
+ if (_disposed) return;
+ if (_keyExists(RegistryHive.LocalMachine, RealmJoinInfo.UninstallRealmJoinPath))
+ {
+ AttachUninstallWatcher();
+ return;
+ }
+ ArmAppearanceParentWatcher(
+ role: "uninstall",
+ hive: RegistryHive.LocalMachine,
+ parentPath: RealmJoinInfo.UninstallRootPath,
+ targetSubKeyName: RealmJoinInfo.UninstallRealmJoinKeyName,
+ targetFullPath: RealmJoinInfo.UninstallRealmJoinPath,
+ slot: w => { lock (_stateLock) { _uninstallAppearanceWatcher = w; } },
+ clearSlot: () => { lock (_stateLock) { _uninstallAppearanceWatcher = null; } },
+ onAppeared: AttachUninstallWatcher);
+ }
+
+ private void AttachUninstallWatcher()
+ {
+ lock (_stateLock)
+ {
+ if (_disposed || _uninstallWatcher != null) return;
+ try
+ {
+ _uninstallDebounce = new Timer(_ => CheckAutoUpdate(), null, Timeout.Infinite, Timeout.Infinite);
+ _uninstallWatcher = new RegistryWatcher(
+ RegistryHive.LocalMachine,
+ RealmJoinInfo.UninstallRealmJoinPath,
+ watchSubtree: false,
+ view: RegistryView.Registry64,
+ filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet,
+ trace: msg => _logger.Trace($"RealmJoinWatcher(uninstall): {msg}"));
+ _uninstallWatcher.Changed += (s, e) => _uninstallDebounce?.Change(AutoUpdateDebounceMilliseconds, Timeout.Infinite);
+ _uninstallWatcher.Error += (s, ex) => _logger.Warning($"RealmJoinWatcher(uninstall): {ex.Message}");
+ _uninstallWatcher.Start();
+ _logger.Info("RealmJoinWatcher: attached auto-update watcher on Uninstall\\RealmJoin");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"RealmJoinWatcher: failed to attach Uninstall\\RealmJoin watcher: {ex.Message}");
+ }
+ }
+
+ // The key may already carry a newer version than the Detected read (update raced
+ // the appearance), so sweep once on attach — same as the other stages.
+ CheckAutoUpdate();
+ }
+
+ ///
+ /// Re-read the installed RJ version and fire
+ /// when it differs from the baseline. Primary source is the binary's file-version
+ /// resource (carries the channel); the Uninstall key's DisplayVersion is the
+ /// fallback while the updater still holds the file. Unreadable ⇒ no event, baseline kept.
+ ///
+ private void CheckAutoUpdate()
+ {
+ try
+ {
+ var info = _readVersionInfo();
+ var newVersion = info.ProductVersion;
+ var newChannel = info.ReleaseChannel;
+ if (string.IsNullOrEmpty(newVersion))
+ {
+ newVersion = _readUninstallDisplayVersion();
+ newChannel = null;
+ if (string.IsNullOrEmpty(newVersion)) return;
+ }
+
+ string? previousVersion;
+ string? channelForEvent;
+ lock (_stateLock)
+ {
+ previousVersion = _baselineVersion;
+ if (previousVersion == null)
+ {
+ // Detected could not read the binary — seed silently, nothing to compare.
+ _baselineVersion = newVersion;
+ _baselineChannel = newChannel;
+ return;
+ }
+ if (string.Equals(previousVersion, newVersion, StringComparison.OrdinalIgnoreCase)) return;
+
+ channelForEvent = newChannel ?? _baselineChannel;
+ _baselineVersion = newVersion;
+ _baselineChannel = channelForEvent;
+ }
+
+ _logger.Info($"RealmJoinWatcher: RJ auto-update detected ({previousVersion} -> {newVersion}, releaseChannel={channelForEvent ?? ""})");
+ try { RealmJoinAutoUpdateDetected?.Invoke(this, new RealmJoinAutoUpdateDetectedEventArgs(previousVersion, newVersion!, channelForEvent)); }
+ catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinAutoUpdateDetected handler threw", ex); }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"RealmJoinWatcher: CheckAutoUpdate threw: {ex.Message}");
+ }
+ }
+
private void CheckMachinePackages()
{
CheckPackages(
@@ -766,6 +932,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
MaybeFirePackageEvents(RealmJoinPackageScope.User, snap, _hkuPackagesStarted, _hkuPackagesCompleted);
internal void TriggerNotifyRealmJoinPresenceFromTest(int? phase) => NotifyRealmJoinPresence(phase);
+ internal void TriggerCheckAutoUpdateFromTest() => CheckAutoUpdate();
+ internal string? BaselineVersionForTest { get { lock (_stateLock) { return _baselineVersion; } } }
// Test seam — directly seed the dedup sets without touching the registry, so that a
// subsequent MaybeFirePackageEvents observation can verify suppression behavior.
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs
index 69d3d109..0ea0e452 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs
@@ -21,6 +21,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
private const string SourceLabel = "RealmJoinWatcher";
private const string RegistryKeyHint = @"HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters";
+ private const string UninstallRegistryKeyHint = @"HKLM\" + RealmJoinInfo.UninstallRealmJoinPath;
private readonly RealmJoinWatcher _watcher;
private readonly ISignalIngressSink _ingress;
@@ -39,6 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_watcher.RealmJoinPhaseChanged += OnPhaseChanged;
_watcher.RealmJoinPackageStarted += OnPackageStarted;
_watcher.RealmJoinPackageCompleted += OnPackageCompleted;
+ _watcher.RealmJoinAutoUpdateDetected += OnAutoUpdateDetected;
}
public void Dispose()
@@ -48,6 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_watcher.RealmJoinPhaseChanged -= OnPhaseChanged;
_watcher.RealmJoinPackageStarted -= OnPackageStarted;
_watcher.RealmJoinPackageCompleted -= OnPackageCompleted;
+ _watcher.RealmJoinAutoUpdateDetected -= OnAutoUpdateDetected;
}
// Test seams
@@ -57,6 +60,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
internal void TriggerPhaseChangedFromTest(int prev, int curr) => OnPhaseChanged(this, new RealmJoinPhaseChangedEventArgs(prev, curr));
internal void TriggerPackageStartedFromTest(string scope, RealmJoinPackageSnapshot snap) => OnPackageStarted(this, new RealmJoinPackageEventArgs(scope, snap));
internal void TriggerPackageCompletedFromTest(string scope, RealmJoinPackageSnapshot snap) => OnPackageCompleted(this, new RealmJoinPackageEventArgs(scope, snap));
+ internal void TriggerAutoUpdateDetectedFromTest(string previousVersion, string newVersion, string? releaseChannel) =>
+ OnAutoUpdateDetected(this, new RealmJoinAutoUpdateDetectedEventArgs(previousVersion, newVersion, releaseChannel));
private void OnDetected(object? sender, RealmJoinDetectedEventArgs e)
{
@@ -177,6 +182,56 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
occurredAtUtc: _clock.UtcNow);
}
+ private void OnAutoUpdateDetected(object? sender, RealmJoinAutoUpdateDetectedEventArgs e)
+ {
+ // Dual-emission: (1) the typed signal overrides the persisted version facts so
+ // final-status / replay carry the build that ran the deployment; (2) the timeline
+ // entry makes the self-update visible next to realmjoin_detected.
+ var payload = new Dictionary(StringComparer.Ordinal)
+ {
+ [DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = e.PreviousVersion,
+ [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = e.NewVersion,
+ };
+ if (!string.IsNullOrEmpty(e.ReleaseChannel))
+ {
+ payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = e.ReleaseChannel!;
+ }
+
+ _ingress.Post(
+ kind: DecisionSignalKind.RealmJoinAutoUpdateDetected,
+ occurredAtUtc: _clock.UtcNow,
+ sourceOrigin: SourceLabel,
+ evidence: new Evidence(
+ kind: EvidenceKind.Derived,
+ identifier: "realmjoin-autoupdate-detected-v1",
+ summary: $"RealmJoin self-update observed ({e.PreviousVersion} -> {e.NewVersion}, releaseChannel={e.ReleaseChannel ?? ""})",
+ derivationInputs: new Dictionary(StringComparer.Ordinal)
+ {
+ ["registryKey"] = UninstallRegistryKeyHint,
+ }),
+ payload: payload);
+
+ var data = new Dictionary(StringComparer.Ordinal)
+ {
+ ["previousVersion"] = e.PreviousVersion,
+ ["newVersion"] = e.NewVersion,
+ ["registryKey"] = UninstallRegistryKeyHint,
+ };
+ if (!string.IsNullOrEmpty(e.ReleaseChannel))
+ {
+ data["releaseChannel"] = e.ReleaseChannel!;
+ }
+ var channelTail = string.IsNullOrEmpty(e.ReleaseChannel) ? string.Empty : $", channel={e.ReleaseChannel}";
+ _post.Emit(
+ eventType: SharedConstants.EventTypes.RealmJoinAutoUpdateDetected,
+ source: SourceLabel,
+ message: $"RealmJoin auto-updated {e.PreviousVersion} -> {e.NewVersion}{channelTail}",
+ severity: EventSeverity.Info,
+ immediateUpload: true,
+ data: data,
+ occurredAtUtc: _clock.UtcNow);
+ }
+
private void OnPackageStarted(object? sender, RealmJoinPackageEventArgs e)
{
var displayName = TruncateDisplayName(e.DisplayName);
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs
index f8a08f3d..73d0475e 100644
--- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs
@@ -837,5 +837,73 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.True(data.ContainsKey("realmjoinDetectedUtc"));
Assert.True(data.ContainsKey("realmjoinResolvedUtc"));
}
+
+ [Fact]
+ public void RealmJoinAutoUpdateDetected_overrides_version_facts_without_touching_gate_or_deadline()
+ {
+ var engine = new DecisionEngine();
+ var state = PrimeClassicAwaitingDesktop(engine);
+
+ var detected = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
+ new Dictionary
+ {
+ [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0",
+ [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.4",
+ [DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "canary",
+ }));
+ var deadlineBefore = Assert.Single(detected.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
+
+ // Channel omitted (registry DisplayVersion fallback) — the Detected channel must survive.
+ var updated = engine.Reduce(detected.NewState, MakeSignal(6, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(14),
+ new Dictionary
+ {
+ [DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.4",
+ [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.18",
+ }));
+
+ Assert.Null(updated.Transition.DeadEndReason);
+ Assert.Equal("4.21.18", updated.NewState.RealmJoinFacts.ProductVersion!.Value);
+ Assert.Equal("canary", updated.NewState.RealmJoinFacts.ReleaseChannel!.Value);
+ Assert.Equal(detected.NewState.Stage, updated.NewState.Stage);
+ Assert.False(DecisionEngine.RealmJoinGateOpen(updated.NewState));
+ var deadlineAfter = Assert.Single(updated.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
+ Assert.Equal(deadlineBefore.DueAtUtc, deadlineAfter.DueAtUtc);
+ Assert.Null(updated.NewState.RealmJoinFacts.LastActivityUtc);
+ Assert.Empty(updated.Effects);
+
+ // Channel supplied — overrides too.
+ var again = engine.Reduce(updated.NewState, MakeSignal(7, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(15),
+ new Dictionary
+ {
+ [DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.18",
+ [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.22.0",
+ [DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "release",
+ }));
+ Assert.Equal("4.22.0", again.NewState.RealmJoinFacts.ProductVersion!.Value);
+ Assert.Equal("release", again.NewState.RealmJoinFacts.ReleaseChannel!.Value);
+ }
+
+ [Fact]
+ public void RealmJoinAutoUpdateDetected_without_version_dead_ends_and_keeps_facts()
+ {
+ var engine = new DecisionEngine();
+ var state = PrimeClassicAwaitingDesktop(engine);
+ var detected = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
+ new Dictionary
+ {
+ [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0",
+ [DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.4",
+ }));
+
+ var step = engine.Reduce(detected.NewState, MakeSignal(6, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(14),
+ new Dictionary
+ {
+ [DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.4",
+ }));
+
+ Assert.Equal("realmjoin_autoupdate_missing_version", step.Transition.DeadEndReason);
+ Assert.Equal("4.21.4", step.NewState.RealmJoinFacts.ProductVersion!.Value);
+ }
+
}
}
diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
index 73a67f99..a2ba772f 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
@@ -128,6 +128,7 @@ export const EVENT_TYPES_CATALOG = {
'realmjoin_first_deployment_incomplete',
'realmjoin_package_started',
'realmjoin_package_completed',
+ 'realmjoin_autoupdate_detected',
],
deployment_events: [
'modern_deployment_log',
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs
index 864b0e6a..a3b627b9 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs
@@ -55,6 +55,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
// — the SemVer prerelease tag, absent tag == stable release).
public const string ProductVersion = "productVersion";
public const string ReleaseChannel = "releaseChannel";
+ // Set on RealmJoinAutoUpdateDetected: the version the facts carried before the
+ // self-update (ProductVersion/ReleaseChannel then describe the NEW build).
+ public const string PreviousVersion = "previousVersion";
}
///
@@ -387,6 +390,47 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// the realmjoin_first_deployment_incomplete Warning, cancels the hard-timeout
/// deadline and releases the completion gate — for us RJ is finished at that point.
///
+ ///
+ /// RJ self-update observed after detection. Overrides the set-once version facts so
+ /// /
+ /// describe the build that actually ran the deployment. Pure bookkeeping: no gate, no
+ /// deadline, no activity credit (the update precedes the deployment, it is not
+ /// deployment progress). Missing productVersion dead-ends.
+ ///
+ private DecisionStep HandleRealmJoinAutoUpdateDetectedV1(DecisionState state, DecisionSignal signal)
+ {
+ var newVersion = TryReadString(signal, RealmJoinPayloadKeys.ProductVersion);
+ if (string.IsNullOrEmpty(newVersion))
+ {
+ var deadEnd = BumpStepBookkeeping(state, signal);
+ var deadEndTransition = BuildDeadEndTransition(
+ state: state,
+ signal: signal,
+ nextStepIndex: deadEnd.StepIndex,
+ trigger: nameof(DecisionSignalKind.RealmJoinAutoUpdateDetected),
+ deadEndReason: "realmjoin_autoupdate_missing_version");
+ return new DecisionStep(deadEnd, deadEndTransition, Array.Empty());
+ }
+
+ var nextStep = state.StepIndex + 1;
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
+ builder.RealmJoinFacts = state.RealmJoinFacts.WithVersionOverride(
+ newVersion!,
+ TryReadString(signal, RealmJoinPayloadKeys.ReleaseChannel),
+ signal.SessionSignalOrdinal);
+
+ var bookkept = builder.Build();
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.RealmJoinAutoUpdateDetected));
+ return new DecisionStep(bookkept, transition, Array.Empty());
+ }
+
private DecisionStep HandleRealmJoinPhaseChangedV1(DecisionState state, DecisionSignal signal)
{
var currentPhase = TryReadPhase(signal);
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
index 2a96bb78..cd4c3230 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
@@ -144,6 +144,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
(DecisionSignalKind.RealmJoinDetected, 1) => HandleRealmJoinDetectedV1(state, signal),
(DecisionSignalKind.RealmJoinResolved, 1) => HandleRealmJoinResolvedV1(state, signal),
(DecisionSignalKind.RealmJoinPhaseChanged, 1) => HandleRealmJoinPhaseChangedV1(state, signal),
+ (DecisionSignalKind.RealmJoinAutoUpdateDetected, 1) => HandleRealmJoinAutoUpdateDetectedV1(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 a87ba5c1..6a224f75 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
@@ -125,5 +125,11 @@ namespace AutopilotMonitor.DecisionCore.Signals
// 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,
+ // RJ replaced its own binary after detection (tray AppUpdater on first start: "New
+ // version found …, update required" → service restarts on the new build minutes after
+ // RealmJoinDetected captured the MSI-shipped version). Bookkeeping-only: overrides
+ // RealmJoinFacts.ProductVersion/ReleaseChannel so the persisted facts match what
+ // actually ran the deployment. Never touches the gate or the timeout deadline.
+ RealmJoinAutoUpdateDetected,
}
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs
index 695908b8..0d6927d3 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs
@@ -167,6 +167,29 @@ namespace AutopilotMonitor.DecisionCore.State
lastActivityUtc: LastActivityUtc);
}
+ ///
+ /// Auto-update override of the set-once version facts ( /
+ /// deliberately refuse replays of Detected; a self-update
+ /// is the one legitimate later write). Empty is a no-op;
+ /// a null keeps the existing channel fact.
+ ///
+ public RealmJoinFacts WithVersionOverride(string productVersion, string? releaseChannel, long sourceSignalOrdinal)
+ {
+ if (string.IsNullOrEmpty(productVersion)) return this;
+ return new RealmJoinFacts(
+ detectedUtc: DetectedUtc,
+ resolvedUtc: ResolvedUtc,
+ lastDeploymentPhase: LastDeploymentPhase,
+ outcome: Outcome,
+ selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
+ productVersion: new SignalFact(productVersion, sourceSignalOrdinal),
+ releaseChannel: string.IsNullOrEmpty(releaseChannel)
+ ? ReleaseChannel
+ : new SignalFact(releaseChannel!, sourceSignalOrdinal),
+ packages: Packages,
+ lastActivityUtc: LastActivityUtc);
+ }
+
public RealmJoinFacts WithResolved(DateTime utc, int phase, long sourceSignalOrdinal)
{
if (ResolvedUtc != null) return this; // set-once
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index a1a1974d..0d58643c 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -547,6 +547,11 @@ namespace AutopilotMonitor.Shared
public const string RealmJoinFirstDeploymentIncomplete = "realmjoin_first_deployment_incomplete";
public const string RealmJoinPackageStarted = "realmjoin_package_started";
public const string RealmJoinPackageCompleted = "realmjoin_package_completed";
+ // Info: RJ replaced its own binary after realmjoin_detected (tray AppUpdater on
+ // first start). Data: previousVersion, newVersion, releaseChannel. Source of truth
+ // for the version that ran the deployment — realmjoin_detected carries the
+ // MSI-shipped build.
+ public const string RealmJoinAutoUpdateDetected = "realmjoin_autoupdate_detected";
// =====================================================================
// Single-source consolidation (2026-05-29): event-type string literals
diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx
index 26f9eabe..92f34495 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx
@@ -21,6 +21,7 @@ interface ProxyConfigData { [key: string]: unknown; proxyType?: string; type?: s
interface AutopilotProfileData { [key: string]: unknown; PolicyDownloadDate?: string; AutopilotCreationDate?: string; autopilotModeLabel?: string; domainJoinMethodLabel?: string }
interface ImeVersionData { [key: string]: unknown; version?: string; agentVersion?: string }
interface RealmJoinInfoData { [key: string]: unknown; productVersion?: string; releaseChannel?: string }
+interface RealmJoinAutoUpdateData { [key: string]: unknown; previousVersion?: string; newVersion?: string; releaseChannel?: string }
interface TpmStatusData { [key: string]: unknown; specVersion?: string }
interface BitLockerStatusData { [key: string]: unknown; volumes?: unknown[] }
interface DeviceLocationData { [key: string]: unknown; country?: string; Country?: string; timezone?: string; Timezone?: string }
@@ -189,7 +190,17 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
session?.isCloudPc === true || `${autopilotProfileMissingEvent?.isCloudPc}` === "true";
const aadJoinStatus = getEventData("aad_join_status");
const imeVersion = getEventData("ime_agent_version");
- const realmJoinInfo = getEventData("realmjoin_detected");
+ // realmjoin_detected carries the MSI-shipped build; the tray often self-updates minutes later
+ // (realmjoin_autoupdate_detected). The latest auto-update is the version that ran the deployment.
+ const realmJoinDetected = getEventData("realmjoin_detected");
+ const realmJoinAutoUpdate = getEventData("realmjoin_autoupdate_detected");
+ const realmJoinInfo: RealmJoinInfoData | null = realmJoinAutoUpdate?.newVersion
+ ? { productVersion: realmJoinAutoUpdate.newVersion, releaseChannel: realmJoinAutoUpdate.releaseChannel ?? realmJoinDetected?.releaseChannel }
+ : realmJoinDetected;
+ const realmJoinInitialVersion =
+ realmJoinAutoUpdate?.newVersion && realmJoinDetected?.productVersion && realmJoinDetected.productVersion !== realmJoinAutoUpdate.newVersion
+ ? realmJoinDetected.productVersion
+ : null;
const bitLockerStatus = getEventData("bitlocker_status");
const secureBootStatus = getEventData("secureboot_status");
const tpmStatus = getEventData("tpm_status");
@@ -478,9 +489,10 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
)}
diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts
index 65475958..3e264171 100644
--- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts
+++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts
@@ -623,6 +623,7 @@ export const SHARED_MANIFEST = {
"prior_run_died_with_state",
"provisioning_package_scan",
"proxy_configuration",
+ "realmjoin_autoupdate_detected",
"realmjoin_detected",
"realmjoin_first_deployment_incomplete",
"realmjoin_package_completed",
diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json
index 477d3630..cb4a6148 100644
--- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json
+++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json
@@ -619,6 +619,7 @@
"prior_run_died_with_state",
"provisioning_package_scan",
"proxy_configuration",
+ "realmjoin_autoupdate_detected",
"realmjoin_detected",
"realmjoin_first_deployment_incomplete",
"realmjoin_package_completed",
diff --git a/tests/fixtures/signal-kinds/realmjoin-autoupdate-detected-v1.json b/tests/fixtures/signal-kinds/realmjoin-autoupdate-detected-v1.json
new file mode 100644
index 00000000..2e18329d
--- /dev/null
+++ b/tests/fixtures/signal-kinds/realmjoin-autoupdate-detected-v1.json
@@ -0,0 +1,7 @@
+# RealmJoinAutoUpdateDetected v1 — RealmJoin self-update after detection.
+# Posted by RealmJoinWatcherAdapter when the HKLM\...\Uninstall\RealmJoin key changes and the
+# re-read RealmJoin.exe version differs from the one reported on RealmJoinDetected (tray
+# AppUpdater on first start; gktatooine session 946ccbd6: 4.21.4 -> 4.21.18 nine minutes after
+# detection). Reducer: HandleRealmJoinAutoUpdateDetectedV1 → overrides
+# RealmJoinFacts.ProductVersion / ReleaseChannel. Bookkeeping-only: no gate, no deadline.
+{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"RealmJoinAutoUpdateDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-08-30T13:33:27.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-autoupdate-detected-v1","Summary":"RealmJoin self-update observed (4.21.4 -> 4.21.18, releaseChannel=canary)","DerivationInputs":{"registryKey":"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\RealmJoin"}},"Payload":{"previousVersion":"4.21.4","productVersion":"4.21.18","releaseChannel":"canary"}}