Registry second pillar: IME app state from HKLM with log reconciliation

P2 of the 2026-08-17 IME code audit. IME log wording is an unstable
contract (audit found 9 silently dead patterns, reworded lines, quote
drift, [DO TEL] removal); the registry is IME's internal product
contract - the ESP page and Intune reporting consume it.

ImeRegistryAppStateObserver + ImeRegistryAppStateHost (always-on
observability, no DecisionEngine coupling):
- one recursive RegistryWatcher on HKLM\...\IntuneManagementExtension
  covers Win32Apps (EnforcementStateMessage JSON, ExitCode, Intent),
  EspTrackingWin32Apps and SideCarPolicies\StatusServiceReports;
  2 s debounced snapshot-and-diff + 60 s periodic fallback tick
  (poll-only degraded mode if the watcher fails to arm)
- silent startup baseline: Win32Apps keys survive re-enrollments,
  pre-existing state never replays as fresh events
- registry_app_state (Info, state-change-only, 200/session cap with
  one-shot cap notice)
- app_state_reconciliation (Warning, once per app, immediate upload):
  a changed entry terminal for >= 90 s is compared against the
  log-derived AppPackageState - divergence is the built-in
  log-pattern drift alarm (registry = truth, logs = narrative)

Event types dual-registered (Constants + MCP resource-catalog + shared
manifests regenerated). OKF concept doc agent/registry-app-state-pillar.md
incl. WDP Paket-3 reuse notes. Tests: pure parse/diff/divergence logic +
tick flow via snapshot override (baseline silence, emission, dwell,
fire-once, cap). All suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-18 01:25:54 +02:00
parent 68eb2da283
commit c05ea4d63b
11 changed files with 1109 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
---
type: concept
title: Registry App-State Second Pillar
description: Why the agent reads IME's authoritative per-app state straight from the registry (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), how the snapshot-and-diff observer works, and how registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
resource: agent
tags: [ime, registry, win32apps, observability, reconciliation, pattern-drift, wdp]
timestamp: 2026-08-18
---
# Schema
## Why a second pillar
The 2026-08-17 audit of the decompiled IME (1.97.107 / 1.104.102) proved that IME log
wording is an unstable contract: Microsoft removed the old V1/V2 processing pipeline
(killing 9 patterns silently), reworded the post-remediation line, changed the
report-delta quoting between 1.97 and 1.104, and deleted `[DO TEL]` entirely. The
registry, by contrast, is IME's *internal product contract* — the ESP page and Intune
reporting consume it — and cannot drift casually.
Architecture decision (Oliver, 2026-08-17): **hybrid**. Logs stay the narrative
(downloads, retries, exit codes, scripts — the timeline). The registry is the stable
truth for terminal state. Divergence between the two IS the alarm that log patterns
drifted — turning silent data loss into a Warning event.
## Observed surfaces
All under `HKLM\SOFTWARE\Microsoft\IntuneManagementExtension` (64-bit view), verified
against decompiled IME source:
| Surface | Content |
|---|---|
| `Win32Apps\<userGuid>\<appGuid>_<rev>` | `EnforcementStateMessage` JSON (EnforcementState + ErrorCode), `ExitCode`, `Intent`. Device context = `Guid.Empty` user. Non-GUID subkeys (GRS, OperationalState, Reporting, ProvisioningProgress) are skipped. |
| `EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev>` | Which apps IME registered for ESP tracking, `EspTrackingWin32AppPhase`. |
| `SideCarPolicies\StatusServiceReports\<userGuid>\<appId>` | The `AppInstallStatusReport` the ESP page renders (`Status`: 1000=Installed, 1001=Installing, 3000=Failed, 2000er=NotApplicable). |
## Mechanism
`ImeRegistryAppStateObserver` + `ImeRegistryAppStateHost` (always-on, no config gate —
precedent `EspPolicyProviderStallHost`):
- **One recursive `RegistryWatcher`** on the IME root covers all three surfaces;
changes are debounced (2 s trailing edge) into a tick. A 60-s periodic tick is both
the reconciliation-settle evaluator and the poll-only fallback when the watcher
fails to arm (`collector_degraded`, once).
- **Snapshot-and-diff**: `RegNotifyChangeKeyValue` gives coalesced key-scope edges
only, so every tick re-reads the surfaces and diffs against the previous snapshot.
- **Silent baseline**: the state present at agent start is captured without events.
Win32Apps keys survive re-enrollments — replaying them would be the registry twin
of the historic-IME-replay bug. Only apps that *change during the session* are
reported or judged.
- **`registry_app_state`** (Info): emitted per real field change
(enforcementState/errorCode/exitCode/statusServiceStatus/espTracked/espPhase),
capped at 200/session (cap announced once as Warning).
- **`app_state_reconciliation`** (Warning, once per app, immediate upload): after a
changed entry has been terminal (EnforcementState 1000er=success / 5000er=error,
or StatusService 1000er/3000) for ≥ 90 s, its outcome is compared against the
log-derived `AppPackageState` from `ImeLogHost.AllKnownPackageStates`. Divergence
reasons: `registry_error_log_installed`, `registry_success_log_error`,
`app_unknown_to_log_tracking` (only when the tracker is actively tracking apps —
an idle tracker is not judgeable).
- **Pure observability**: nothing feeds the DecisionEngine (same rule as
oobe-state observability).
## WDP reuse (Paket 3)
The same host archetype (RegistryWatcher subtree + debounce + snapshot-diff) is the
intended vehicle for WDP live progress: watch
`HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\DevicePreparation\BootstrapperAgent`
(`ProgressUpdates` rows, `ResumeContext.ProvisioningResult` terminal marker) with the
RealmJoinWatcher appearance-watcher pattern for not-yet-existing keys, plus an
Event-142 channel watcher (`Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider`,
channel handling precedent in `DiagnosticsPackageService`). Win32Apps keys are written
on WDP devices too — the pillar works on both rails unchanged.
# Examples
Timeline evidence of a drifted pattern set: apps install fine (registry says
EnforcementState 1000) but the timeline shows no `app_install_completed` — each app
raises one `app_state_reconciliation` with `reason=app_unknown_to_log_tracking`.
Operator response: run `/ime-pattern-validate` against the session, fix patterns,
backend-deploy (no agent release needed for pattern fixes).
# Citations
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs`
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeRegistryAppStateHost.cs`
- `tasks/ime-v1-code-audit-2026-08-17.md` (decompiled-source audit; registry surface catalog)
- Decompile archive: `okieselbach/ime-decompiles` (private), `C:\Code\GitHubRepos\ime-decompiles\<version>\decompiled`
+1
View File
@@ -25,6 +25,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
# Rules
+4
View File
@@ -1,5 +1,9 @@
# Log
## 2026-08-18
* **New**: `agent/registry-app-state-pillar.md` — the registry second pillar for IME app state (audit follow-up): snapshot-and-diff watcher on `HKLM\...\IntuneManagementExtension` (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), `registry_app_state` + `app_state_reconciliation` events, silent startup baseline, WDP reuse notes.
## 2026-08-17
* **Update**: `mcp/tool-telemetry.md` — the `tool_call` stderr line now carries `errorMessage` (capped at 300 chars; thrown message or the leading `toolError` text, absent on success), so error drilldowns name the failure class instead of guessing from args. Same change: `get_rule_stats` omits the per-rule daily `trend` rows by default (they drove 50% of calls over the response cap at platform scope); `includeTrends=true` opts back in, the response carries a `trendsNote` marker when stripped.
@@ -0,0 +1,264 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// Registry second pillar (audit 2026-08-17): pure parsing/diff logic plus the
/// tick flow (baseline silence, state-change emission, reconciliation dwell)
/// driven through the snapshot override — no live registry.
/// </summary>
public sealed class ImeRegistryAppStateObserverTests
{
private const string App1 = "11111111-1111-1111-1111-111111111111";
private const string App2 = "22222222-2222-2222-2222-222222222222";
private static readonly DateTime T0 = new DateTime(2026, 8, 18, 8, 0, 0, DateTimeKind.Utc);
// ── pure helpers ────────────────────────────────────────────────────────
[Theory]
[InlineData("11111111-1111-1111-1111-111111111111_1", "11111111-1111-1111-1111-111111111111")]
[InlineData("11111111-1111-1111-1111-111111111111", "11111111-1111-1111-1111-111111111111")]
[InlineData("GRS", null)]
[InlineData("", null)]
[InlineData("not-a-guid_1", null)]
public void ExtractAppId_parses_app_keys_and_rejects_non_app_keys(string keyName, string? expected)
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ExtractAppId(keyName));
[Fact]
public void ParseEnforcementStateMessage_reads_state_and_error()
{
var (state, error) = ImeRegistryAppStateObserver.ParseEnforcementStateMessage(
"{\"EnforcementState\":1000,\"ErrorCode\":0}");
Assert.Equal(1000, state);
Assert.Equal(0L, error);
Assert.Equal((null, null), ImeRegistryAppStateObserver.ParseEnforcementStateMessage("not json"));
Assert.Equal((null, null), ImeRegistryAppStateObserver.ParseEnforcementStateMessage("[]"));
}
[Theory]
[InlineData(1000, "success")]
[InlineData(1004, "success")]
[InlineData(2009, "inProgress")]
[InlineData(3000, "requirementsNotMet")]
[InlineData(5003, "error")]
[InlineData(6001, "notAttempted")]
[InlineData(42, "unknown")]
public void ClassifyEnforcementState_bands(int state, string expected)
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyEnforcementState(state));
[Fact]
public void DiffSnapshots_reports_only_changed_fields()
{
var prev = Snapshot((App1, 2009, null));
var next = Snapshot((App1, 1000, 0), (App2, 2000, null));
var changes = ImeRegistryAppStateObserver.DiffSnapshots(prev, next);
Assert.Equal(2, changes.Count);
var app1Change = changes.Single(c => c.Entry.AppId == App1);
Assert.Contains("enforcementState", app1Change.ChangedFields);
Assert.Contains("errorCode", app1Change.ChangedFields);
Assert.False(app1Change.IsNew);
Assert.True(changes.Single(c => c.Entry.AppId == App2).IsNew);
Assert.Empty(ImeRegistryAppStateObserver.DiffSnapshots(next, next));
}
[Fact]
public void IsDivergent_rules()
{
var installed = PackageState(App1, AppInstallationState.Installed);
var errored = PackageState(App1, AppInstallationState.Error);
Assert.True(ImeRegistryAppStateObserver.IsDivergent("error", installed, true, out var r1));
Assert.Equal("registry_error_log_installed", r1);
Assert.True(ImeRegistryAppStateObserver.IsDivergent("success", errored, true, out var r2));
Assert.Equal("registry_success_log_error", r2);
Assert.True(ImeRegistryAppStateObserver.IsDivergent("success", null, true, out var r3));
Assert.Equal("app_unknown_to_log_tracking", r3);
// Tracker idle (no apps at all) — not judgeable, no false alarm.
Assert.False(ImeRegistryAppStateObserver.IsDivergent("success", null, false, out _));
// Agreement.
Assert.False(ImeRegistryAppStateObserver.IsDivergent("success", installed, true, out _));
Assert.False(ImeRegistryAppStateObserver.IsDivergent("error", errored, true, out _));
}
// ── tick flow ───────────────────────────────────────────────────────────
[Fact]
public void Baseline_is_silent_then_changes_emit_state_events()
{
var (sink, clock, observer) = CreateObserver(trackerApps: null);
var current = Snapshot((App1, 2000, null));
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
{
observer.Tick("baseline");
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppState));
current = Snapshot((App1, 1000, 0));
observer.Tick("registry_change");
}
var evt = Assert.Single(Events(sink, SharedEventTypes.RegistryAppState));
Assert.Equal(App1, evt.Payload!["appId"]);
Assert.Equal("1000", evt.Payload["enforcementState"]);
Assert.Equal("success", evt.Payload["enforcementClass"]);
}
[Fact]
public void Unchanged_snapshot_emits_nothing()
{
var (sink, clock, observer) = CreateObserver(trackerApps: null);
var current = Snapshot((App1, 2000, null));
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
{
observer.Tick("baseline");
observer.Tick("periodic");
observer.Tick("periodic");
}
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppState));
}
[Fact]
public void Reconciliation_fires_once_after_settle_delay_on_divergence()
{
var tracker = new List<AppPackageState> { PackageState(App1, AppInstallationState.Installed) };
var (sink, clock, observer) = CreateObserver(trackerApps: tracker);
var current = Snapshot((App1, 2000, null));
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
{
observer.Tick("baseline");
current = Snapshot((App1, 5003, 101)); // registry error, log says Installed
observer.Tick("registry_change");
// Before the settle delay: no reconciliation yet.
Assert.Empty(Events(sink, SharedEventTypes.AppStateReconciliation));
clock.Advance(ImeRegistryAppStateObserver.ReconcileSettleDelay + TimeSpan.FromSeconds(1));
observer.Tick("periodic");
observer.Tick("periodic"); // second pass must not re-emit
}
var rec = Assert.Single(Events(sink, SharedEventTypes.AppStateReconciliation));
Assert.Equal("error", rec.Payload!["registryOutcome"]);
Assert.Equal("registry_error_log_installed", rec.Payload["reason"]);
Assert.Equal("Installed", rec.Payload["logState"]);
}
[Fact]
public void Reconciliation_skips_agreeing_and_baseline_only_apps()
{
var tracker = new List<AppPackageState> { PackageState(App1, AppInstallationState.Installed) };
var (sink, clock, observer) = CreateObserver(trackerApps: tracker);
// App2 is terminal in the BASELINE (pre-existing from an earlier enrollment) and
// never changes — it must never be judged. App1 changes and agrees with the log.
var current = Snapshot((App1, 2000, null), (App2, 5000, 1));
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
{
observer.Tick("baseline");
current = Snapshot((App1, 1000, 0), (App2, 5000, 1));
observer.Tick("registry_change");
clock.Advance(ImeRegistryAppStateObserver.ReconcileSettleDelay + TimeSpan.FromSeconds(1));
observer.Tick("periodic");
}
Assert.Empty(Events(sink, SharedEventTypes.AppStateReconciliation));
}
[Fact]
public void State_event_cap_emits_single_warning_then_suppresses()
{
var (sink, clock, observer) = CreateObserver(trackerApps: null);
var baseline = new ImeRegistrySnapshot();
var flooded = new ImeRegistrySnapshot();
for (var i = 0; i < ImeRegistryAppStateObserver.MaxStateEventsPerSession + 25; i++)
{
var appId = Guid.NewGuid().ToString("D");
baseline.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId).EnforcementState = 2000;
flooded.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId).EnforcementState = 1000;
}
var current = baseline;
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
{
observer.Tick("baseline");
current = flooded;
observer.Tick("registry_change");
}
var events = Events(sink, SharedEventTypes.RegistryAppState);
// Cap payload-carrying events + exactly one cap-notice Warning.
Assert.Equal(ImeRegistryAppStateObserver.MaxStateEventsPerSession + 1, events.Count);
Assert.Single(events, e => e.Payload != null
&& e.Payload.TryGetValue(SignalPayloadKeys.Message, out var m)
&& m.Contains("cap reached"));
}
// ── harness ─────────────────────────────────────────────────────────────
private static (FakeSignalIngressSink sink, VirtualClock clock, ImeRegistryAppStateObserver observer)
CreateObserver(IReadOnlyList<AppPackageState>? trackerApps)
{
var sink = new FakeSignalIngressSink();
var clock = new VirtualClock(T0);
var post = new InformationalEventPost(sink, clock);
var observer = new ImeRegistryAppStateObserver(
post,
logger: null,
clock: clock,
trackerStateProbe: trackerApps == null ? null : () => trackerApps);
return (sink, clock, observer);
}
private static ImeRegistrySnapshot Snapshot(params (string appId, int enforcementState, long? errorCode)[] apps)
{
var snapshot = new ImeRegistrySnapshot();
foreach (var (appId, state, error) in apps)
{
var entry = snapshot.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId);
entry.EnforcementState = state;
entry.ErrorCode = error;
}
return snapshot;
}
private static AppPackageState PackageState(string id, AppInstallationState state)
{
var pkg = new AppPackageState(id, listPos: 0);
if (state == AppInstallationState.Installed)
{
// Route through Installing first — a bare Installed would trip the
// inverse-detection auto-downgrade heuristic (Installed w/o activity -> Skipped).
pkg.UpdateState(AppInstallationState.Installing);
}
pkg.UpdateState(state);
return pkg;
}
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> Events(FakeSignalIngressSink sink, string eventType) =>
sink.Posted.Where(p =>
p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == eventType).ToList();
}
}
@@ -0,0 +1,577 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Registry "second pillar" for IME app state (audit 2026-08-17, architecture decision:
/// registry = stable truth, IME logs = narrative). IME persists its authoritative per-app
/// state under <c>HKLM\SOFTWARE\Microsoft\IntuneManagementExtension</c>:
/// <list type="bullet">
/// <item><c>Win32Apps\&lt;userGuid&gt;\&lt;appGuid&gt;_&lt;rev&gt;</c> — EnforcementStateMessage JSON
/// (EnforcementState + ErrorCode), ExitCode, Intent. Device context = Guid.Empty user.</item>
/// <item><c>EspTrackingWin32Apps\&lt;userGuid&gt;\&lt;appGuid&gt;_&lt;rev&gt;</c> — which apps IME
/// registered for ESP tracking and in which phase.</item>
/// <item><c>SideCarPolicies\StatusServiceReports\&lt;userGuid&gt;\&lt;appId&gt;</c> — the exact
/// AppInstallStatusReport the ESP page renders (Status 1000=Installed .. 3000=Failed).</item>
/// </list>
/// The observer is snapshot-and-diff driven (RegistryWatcher gives key-scope edges only,
/// coalesced — a per-write parse is impossible by design): every tick re-reads the three
/// surfaces and emits <c>registry_app_state</c> on real field changes. Pre-existing state at
/// agent start is the silent baseline — Win32Apps keys survive re-enrollments, so replaying
/// them as fresh events would be the registry twin of the historic-IME-replay bug.
///
/// Reconciliation (= built-in log-pattern drift alarm): once a registry entry that changed
/// during THIS session has been terminal for <see cref="ReconcileSettleDelay"/>, its outcome
/// is compared against the log-derived <see cref="AppPackageState"/>. A divergence means the
/// IME log patterns missed or mis-classified the app — emitted once per app as
/// <c>app_state_reconciliation</c> (Warning). Pure observability: nothing here feeds the
/// DecisionEngine.
/// </summary>
internal sealed class ImeRegistryAppStateObserver
{
public const string SourceLabel = "RegistryAppState";
internal const string ImeRootKeyPath = @"SOFTWARE\Microsoft\IntuneManagementExtension";
// Safety cap: a pathological churn (or a wrong diff) must not flood the timeline.
// 200 covers ~25 apps x 8 state transitions; the cap itself is announced once.
internal const int MaxStateEventsPerSession = 200;
// The log tracker legitimately lags the registry (log write + 100 ms poll + phase
// gating), so divergence is only evaluated after the registry state has been stable
// terminal for this long.
internal static readonly TimeSpan ReconcileSettleDelay = TimeSpan.FromSeconds(90);
private readonly InformationalEventPost _post;
private readonly AgentLogger? _logger;
private readonly IClock _clock;
private readonly Func<IReadOnlyList<AppPackageState>>? _trackerStateProbe;
private readonly object _sync = new object();
private ImeRegistrySnapshot? _last;
private readonly Dictionary<string, DateTime> _terminalSinceUtc =
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _changedDuringSession =
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _reconciliationEmitted =
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private int _stateEventsEmitted;
private bool _capNoticeEmitted;
// Test seam: replaces the live registry walk (same convention as EspTrackingInfoProbe).
internal static Func<ImeRegistrySnapshot>? SnapshotOverride;
internal sealed class ScopedSnapshotOverride : IDisposable
{
private readonly Func<ImeRegistrySnapshot>? _previous;
private int _disposed;
public ScopedSnapshotOverride(Func<ImeRegistrySnapshot> replacement)
{
_previous = SnapshotOverride;
SnapshotOverride = replacement;
}
public void Dispose()
{
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;
SnapshotOverride = _previous;
}
}
public ImeRegistryAppStateObserver(
InformationalEventPost post,
AgentLogger? logger,
IClock clock,
Func<IReadOnlyList<AppPackageState>>? trackerStateProbe)
{
_post = post ?? throw new ArgumentNullException(nameof(post));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_logger = logger;
_trackerStateProbe = trackerStateProbe;
}
/// <summary>
/// One observation pass: snapshot → diff → emit → reconcile. Called from the debounced
/// registry-change edge AND a periodic fallback tick (the settle-delay evaluation must
/// happen even when the registry goes quiet). Fail-soft: never throws.
/// </summary>
public void Tick(string reason)
{
try
{
lock (_sync)
{
var snapshot = SnapshotOverride?.Invoke() ?? ReadSnapshot();
var nowUtc = _clock.UtcNow;
if (_last == null)
{
// Silent baseline — see class doc.
_last = snapshot;
_logger?.Info($"RegistryAppState: baseline captured ({snapshot.Entries.Count} app entries, trigger={reason})");
return;
}
var changes = DiffSnapshots(_last, snapshot);
foreach (var change in changes)
{
_changedDuringSession.Add(change.Entry.Key);
EmitStateEvent(change, nowUtc);
}
TrackTerminalTransitions(snapshot, nowUtc);
EvaluateReconciliations(snapshot, nowUtc);
_last = snapshot;
}
}
catch (Exception ex)
{
_logger?.Debug($"RegistryAppState: tick failed (fail-soft): {ex.Message}");
}
}
// ── snapshot ────────────────────────────────────────────────────────────
internal static ImeRegistrySnapshot ReadSnapshot()
{
var snapshot = new ImeRegistrySnapshot();
using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
using var root = baseKey.OpenSubKey(ImeRootKeyPath);
if (root == null) return snapshot;
ReadWin32Apps(root, snapshot);
ReadEspTracking(root, snapshot);
ReadStatusServiceReports(root, snapshot);
return snapshot;
}
private static void ReadWin32Apps(RegistryKey root, ImeRegistrySnapshot snapshot)
{
using var win32Apps = root.OpenSubKey("Win32Apps");
if (win32Apps == null) return;
foreach (var userName in win32Apps.GetSubKeyNames())
{
if (!LooksLikeGuid(userName)) continue; // skips OperationalState / Reporting / ProvisioningProgress
using var userKey = win32Apps.OpenSubKey(userName);
if (userKey == null) continue;
foreach (var appKeyName in userKey.GetSubKeyNames())
{
var appId = ExtractAppId(appKeyName);
if (appId == null) continue; // skips GRS + non-app keys
using var appKey = userKey.OpenSubKey(appKeyName);
if (appKey == null) continue;
var entry = snapshot.GetOrAdd(userName, appId);
entry.ExitCode = TryReadInt(appKey.GetValue("ExitCode"));
entry.Intent = TryReadInt(appKey.GetValue("Intent"));
using var esm = appKey.OpenSubKey("EnforcementStateMessage");
var json = esm?.GetValue("EnforcementStateMessage") as string;
if (!string.IsNullOrEmpty(json))
{
var (state, error) = ParseEnforcementStateMessage(json!);
entry.EnforcementState = state;
entry.ErrorCode = error;
}
}
}
}
private static void ReadEspTracking(RegistryKey root, ImeRegistrySnapshot snapshot)
{
using var esp = root.OpenSubKey("EspTrackingWin32Apps");
if (esp == null) return;
foreach (var userName in esp.GetSubKeyNames())
{
if (!LooksLikeGuid(userName)) continue;
using var userKey = esp.OpenSubKey(userName);
if (userKey == null) continue;
foreach (var appKeyName in userKey.GetSubKeyNames())
{
var appId = ExtractAppId(appKeyName);
if (appId == null) continue;
using var appKey = userKey.OpenSubKey(appKeyName);
var entry = snapshot.GetOrAdd(userName, appId);
entry.EspTracked = true;
entry.EspPhase = appKey?.GetValue("EspTrackingWin32AppPhase") as string;
}
}
}
private static void ReadStatusServiceReports(RegistryKey root, ImeRegistrySnapshot snapshot)
{
using var reports = root.OpenSubKey(@"SideCarPolicies\StatusServiceReports");
if (reports == null) return;
foreach (var userName in reports.GetSubKeyNames())
{
if (!LooksLikeGuid(userName)) continue;
using var userKey = reports.OpenSubKey(userName);
if (userKey == null) continue;
foreach (var appKeyName in userKey.GetSubKeyNames())
{
var appId = ExtractAppId(appKeyName);
if (appId == null) continue;
using var appKey = userKey.OpenSubKey(appKeyName);
if (appKey == null) continue;
var entry = snapshot.GetOrAdd(userName, appId);
entry.StatusServiceStatus = TryReadInt(appKey.GetValue("Status"));
}
}
}
// ── pure helpers (unit-tested directly) ─────────────────────────────────
internal static bool LooksLikeGuid(string name) =>
!string.IsNullOrEmpty(name) && Guid.TryParse(name, out _);
/// <summary>
/// Win32Apps app keys are <c>&lt;appGuid&gt;_&lt;revision&gt;</c>; StatusServiceReports keys
/// are the bare GUID. Returns the lowercase app GUID or null for non-app keys (GRS, ...).
/// </summary>
internal static string? ExtractAppId(string keyName)
{
if (string.IsNullOrEmpty(keyName)) return null;
var candidate = keyName.Length >= 36 ? keyName.Substring(0, 36) : keyName;
return Guid.TryParse(candidate, out var g) ? g.ToString("D") : null;
}
internal static (int? state, long? errorCode) ParseEnforcementStateMessage(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
int? state = null;
long? error = null;
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
if (doc.RootElement.TryGetProperty("EnforcementState", out var s) && s.ValueKind == JsonValueKind.Number)
state = s.GetInt32();
if (doc.RootElement.TryGetProperty("ErrorCode", out var e) && e.ValueKind == JsonValueKind.Number)
error = e.GetInt64();
}
return (state, error);
}
catch
{
return (null, null);
}
}
internal static int? TryReadInt(object? value)
{
switch (value)
{
case null: return null;
case int i: return i;
case long l: return unchecked((int)l);
case string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed):
return parsed;
default: return null;
}
}
/// <summary>StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104).</summary>
internal static string ClassifyEnforcementState(int state)
{
if (state >= 1000 && state < 2000) return "success";
if (state >= 2000 && state < 3000) return "inProgress";
if (state >= 3000 && state < 4000) return "requirementsNotMet";
if (state >= 5000 && state < 6000) return "error";
if (state >= 6000 && state < 7000) return "notAttempted";
return "unknown";
}
/// <summary>AppInstallStatus (StatusService/ESP page): 1000er = installed, 3000 = failed.</summary>
internal static string? ClassifyStatusServiceStatus(int status)
{
if (status >= 1000 && status < 2000) return "installed";
if (status == 3000) return "failed";
if (status >= 2000 && status < 3000) return "notApplicable";
return null;
}
internal static List<AppRegistryChange> DiffSnapshots(ImeRegistrySnapshot previous, ImeRegistrySnapshot next)
{
var changes = new List<AppRegistryChange>();
foreach (var kv in next.Entries)
{
previous.Entries.TryGetValue(kv.Key, out var prev);
var changed = ChangedFields(prev, kv.Value);
if (changed.Count > 0)
changes.Add(new AppRegistryChange(kv.Value, changed, isNew: prev == null));
}
return changes;
}
internal static List<string> ChangedFields(AppRegistryEntry? prev, AppRegistryEntry next)
{
var changed = new List<string>();
if (prev?.EnforcementState != next.EnforcementState) changed.Add("enforcementState");
if (prev?.ErrorCode != next.ErrorCode) changed.Add("errorCode");
if (prev?.ExitCode != next.ExitCode) changed.Add("exitCode");
if (prev?.StatusServiceStatus != next.StatusServiceStatus) changed.Add("statusServiceStatus");
if ((prev?.EspTracked ?? false) != next.EspTracked) changed.Add("espTracked");
if (!string.Equals(prev?.EspPhase, next.EspPhase, StringComparison.OrdinalIgnoreCase)) changed.Add("espPhase");
return changed;
}
/// <summary>"success" / "error" when the registry outcome is terminal, else null.</summary>
internal static string? TerminalOutcome(AppRegistryEntry entry)
{
if (entry.EnforcementState is int es)
{
var cls = ClassifyEnforcementState(es);
if (cls == "success") return "success";
if (cls == "error") return "error";
}
if (entry.StatusServiceStatus is int ss)
{
var cls = ClassifyStatusServiceStatus(ss);
if (cls == "installed") return "success";
if (cls == "failed") return "error";
}
return null;
}
/// <summary>
/// Divergence rules (observability, conservative — prefer false-negative over noise):
/// registry error vs log Installed, registry success vs log Error, or registry terminal
/// while the tracker (actively tracking other apps) never saw the app at all.
/// </summary>
internal static bool IsDivergent(
string registryOutcome,
AppPackageState? logState,
bool trackerHasAnyApps,
out string reasonCode)
{
if (logState == null)
{
if (trackerHasAnyApps)
{
reasonCode = "app_unknown_to_log_tracking";
return true;
}
reasonCode = string.Empty; // tracker idle (e.g. pre-ESP) — not judgeable
return false;
}
if (registryOutcome == "error" && logState.InstallationState == AppInstallationState.Installed)
{
reasonCode = "registry_error_log_installed";
return true;
}
if (registryOutcome == "success" && logState.InstallationState == AppInstallationState.Error)
{
reasonCode = "registry_success_log_error";
return true;
}
reasonCode = string.Empty;
return false;
}
// ── emission ────────────────────────────────────────────────────────────
private void EmitStateEvent(AppRegistryChange change, DateTime nowUtc)
{
if (_stateEventsEmitted >= MaxStateEventsPerSession)
{
if (!_capNoticeEmitted)
{
_capNoticeEmitted = true;
_post.Emit(
eventType: SharedEventTypes.RegistryAppState,
source: SourceLabel,
message: $"registry_app_state cap reached ({MaxStateEventsPerSession}) — further registry app-state changes are logged locally only",
severity: EventSeverity.Warning,
occurredAtUtc: nowUtc);
}
_logger?.Debug($"RegistryAppState: cap reached, suppressing {change.Entry.AppId}");
return;
}
_stateEventsEmitted++;
var e = change.Entry;
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["appId"] = e.AppId,
["userContext"] = e.UserContext,
["changedFields"] = string.Join(",", change.ChangedFields),
};
if (e.EnforcementState is int es)
{
data["enforcementState"] = es.ToString(CultureInfo.InvariantCulture);
data["enforcementClass"] = ClassifyEnforcementState(es);
}
if (e.ErrorCode is long ec) data["errorCode"] = ec.ToString(CultureInfo.InvariantCulture);
if (e.ExitCode is int xc) data["exitCode"] = xc.ToString(CultureInfo.InvariantCulture);
if (e.Intent is int intent) data["intent"] = intent.ToString(CultureInfo.InvariantCulture);
if (e.StatusServiceStatus is int ss)
{
data["statusServiceStatus"] = ss.ToString(CultureInfo.InvariantCulture);
var cls = ClassifyStatusServiceStatus(ss);
if (cls != null) data["statusServiceClass"] = cls;
}
if (e.EspTracked) data["espTracked"] = "true";
if (!string.IsNullOrEmpty(e.EspPhase)) data["espPhase"] = e.EspPhase!;
var summary = data.TryGetValue("enforcementClass", out var encls) ? encls
: data.TryGetValue("statusServiceClass", out var sscls) ? sscls
: "updated";
_post.Emit(
eventType: SharedEventTypes.RegistryAppState,
source: SourceLabel,
message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}",
severity: EventSeverity.Info,
data: data,
occurredAtUtc: nowUtc);
}
private void TrackTerminalTransitions(ImeRegistrySnapshot snapshot, DateTime nowUtc)
{
foreach (var kv in snapshot.Entries)
{
if (!_changedDuringSession.Contains(kv.Key)) continue; // baseline state — not ours to judge
var outcome = TerminalOutcome(kv.Value);
if (outcome == null)
{
_terminalSinceUtc.Remove(kv.Key); // regressed to non-terminal — reset dwell
continue;
}
if (!_terminalSinceUtc.ContainsKey(kv.Key))
_terminalSinceUtc[kv.Key] = nowUtc;
}
}
private void EvaluateReconciliations(ImeRegistrySnapshot snapshot, DateTime nowUtc)
{
if (_trackerStateProbe == null) return;
List<AppPackageState>? trackerStates = null;
foreach (var kv in _terminalSinceUtc.ToList())
{
if (_reconciliationEmitted.Contains(kv.Key)) continue;
if (nowUtc - kv.Value < ReconcileSettleDelay) continue;
if (!snapshot.Entries.TryGetValue(kv.Key, out var entry)) continue;
var outcome = TerminalOutcome(entry);
if (outcome == null) continue;
trackerStates ??= (_trackerStateProbe() ?? Array.Empty<AppPackageState>()).ToList();
var logState = trackerStates.FirstOrDefault(p =>
string.Equals(p.Id, entry.AppId, StringComparison.OrdinalIgnoreCase));
if (!IsDivergent(outcome, logState, trackerStates.Count > 0, out var reasonCode))
{
_reconciliationEmitted.Add(kv.Key); // agreed (or not judgeable) — done with this app
continue;
}
_reconciliationEmitted.Add(kv.Key);
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["appId"] = entry.AppId,
["userContext"] = entry.UserContext,
["registryOutcome"] = outcome,
["reason"] = reasonCode,
["logState"] = logState?.InstallationState.ToString() ?? "unknown",
};
if (logState != null && !string.IsNullOrEmpty(logState.Name)) data["appName"] = logState.Name;
if (entry.EnforcementState is int es) data["enforcementState"] = es.ToString(CultureInfo.InvariantCulture);
if (entry.ErrorCode is long ec) data["errorCode"] = ec.ToString(CultureInfo.InvariantCulture);
_post.Emit(
eventType: SharedEventTypes.AppStateReconciliation,
source: SourceLabel,
message: $"Registry/log divergence for app {entry.AppId}: registry={outcome}, log={data["logState"]} ({reasonCode}) — IME log patterns may have drifted",
severity: EventSeverity.Warning,
immediateUpload: true,
data: data,
occurredAtUtc: nowUtc);
_logger?.Warning($"RegistryAppState: divergence for {entry.AppId} — registry={outcome}, log={data["logState"]} ({reasonCode})");
}
}
}
/// <summary>Immutable-ish snapshot of the three IME registry surfaces, keyed per user-context + app.</summary>
internal sealed class ImeRegistrySnapshot
{
public const string DeviceContext = "00000000-0000-0000-0000-000000000000";
public Dictionary<string, AppRegistryEntry> Entries { get; } =
new Dictionary<string, AppRegistryEntry>(StringComparer.OrdinalIgnoreCase);
public AppRegistryEntry GetOrAdd(string userContext, string appId)
{
var normalizedUser = Guid.TryParse(userContext, out var g) ? g.ToString("D") : userContext;
var key = normalizedUser + "|" + appId;
if (!Entries.TryGetValue(key, out var entry))
{
entry = new AppRegistryEntry(key, normalizedUser, appId);
Entries[key] = entry;
}
return entry;
}
}
internal sealed class AppRegistryEntry
{
public AppRegistryEntry(string key, string userContext, string appId)
{
Key = key;
UserContext = userContext;
AppId = appId;
}
public string Key { get; }
public string UserContext { get; }
public string AppId { get; }
public int? EnforcementState { get; set; }
public long? ErrorCode { get; set; }
public int? ExitCode { get; set; }
public int? Intent { get; set; }
public int? StatusServiceStatus { get; set; }
public bool EspTracked { get; set; }
public string? EspPhase { get; set; }
}
internal sealed class AppRegistryChange
{
public AppRegistryChange(AppRegistryEntry entry, List<string> changedFields, bool isNew)
{
Entry = entry;
ChangedFields = changedFields;
IsNew = isNew;
}
public AppRegistryEntry Entry { get; }
public List<string> ChangedFields { get; }
public bool IsNew { get; }
}
}
@@ -326,6 +326,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
clock: clock,
startupGate: _startupEventGate));
// Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from
// HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage,
// EspTrackingWin32Apps, StatusServiceReports) via snapshot-and-diff. Emits
// registry_app_state on real changes and app_state_reconciliation when the
// registry outcome diverges from the IME-log-derived state (= built-in pattern-drift
// alarm). Always-on observability host; the tracker probe is read-only.
hosts.Add(new ImeRegistryAppStateHost(
sessionId: sessionId,
tenantId: tenantId,
logger: logger,
ingress: ingress,
clock: clock,
trackerStateProbe: () => imeLogHost.AllKnownPackageStates));
// Deterministic update corroboration — compares the persisted OS build (CurrentBuild.UBR)
// across agent restarts and emits os_build_changed when it differs (session 7443317c:
// build jump proved an OOBE quality update the WU channel never showed). Must be added
@@ -0,0 +1,149 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Hosts the <see cref="ImeRegistryAppStateObserver"/> (registry second pillar, audit
/// 2026-08-17): one recursive <see cref="RegistryWatcher"/> on the IME root — all three
/// observed surfaces (Win32Apps, EspTrackingWin32Apps, SideCarPolicies\StatusServiceReports)
/// live under it — debounced into snapshot-and-diff ticks, plus a 60-s periodic fallback
/// tick. The fallback both evaluates the reconciliation settle-delay when the registry goes
/// quiet AND keeps the pillar alive if the watcher fails to arm (poll-only degraded mode,
/// announced once via <c>collector_degraded</c>). Always-on observability host, no config
/// gate — precedent: <see cref="EspPolicyProviderStallHost"/>.
/// </summary>
internal sealed class ImeRegistryAppStateHost : ICollectorHost
{
public string Name => "ImeRegistryAppState";
private static readonly TimeSpan DebounceDelay = TimeSpan.FromSeconds(2);
private static readonly TimeSpan PeriodicInterval = TimeSpan.FromSeconds(60);
private readonly ImeRegistryAppStateObserver _observer;
private readonly InformationalEventPost _post;
private readonly AgentLogger? _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private RegistryWatcher? _watcher;
private Timer? _debounceTimer;
private Timer? _periodicTimer;
private int _stopped;
public ImeRegistryAppStateHost(
string sessionId,
string tenantId,
AgentLogger? logger,
ISignalIngressSink ingress,
IClock clock,
Func<IReadOnlyList<AppPackageState>>? trackerStateProbe)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_sessionId = sessionId;
_tenantId = tenantId;
_logger = logger;
_post = new InformationalEventPost(ingress, clock);
_observer = new ImeRegistryAppStateObserver(_post, logger, clock, trackerStateProbe);
}
public void Start()
{
// Baseline immediately: pre-existing registry state must be captured silently
// BEFORE the first change edge, or stale entries would replay as fresh events.
_observer.Tick("baseline");
try
{
_watcher = new RegistryWatcher(
RegistryHive.LocalMachine,
ImeRegistryAppStateObserver.ImeRootKeyPath,
watchSubtree: true,
view: RegistryView.Registry64,
filter: RegistryNativeMethods.RegChangeNotifyFilter.Name
| RegistryNativeMethods.RegChangeNotifyFilter.LastSet
| RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic,
trace: msg => _logger?.Debug($"ImeRegistryAppState watcher: {msg}"));
_watcher.Changed += (_, __) => ScheduleDebouncedTick();
_watcher.Error += (_, ex) =>
{
_logger?.Warning($"ImeRegistryAppState watcher error (poll-only fallback continues): {ex.Message}");
};
_watcher.Start();
}
catch (Exception ex)
{
// Watcher arm failure degrades to poll-only (periodic tick below) — announce once.
_logger?.Warning($"ImeRegistryAppState: watcher arm failed, running poll-only: {ex.Message}");
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed", ex);
_watcher?.Dispose();
_watcher = null;
}
_periodicTimer = new Timer(_ => SafeTick("periodic"), null, PeriodicInterval, PeriodicInterval);
_logger?.Info($"ImeRegistryAppStateHost started (watcher={(_watcher != null ? "armed" : "poll-only")})");
}
private void ScheduleDebouncedTick()
{
if (Volatile.Read(ref _stopped) != 0) return;
// One trailing-edge tick per change burst; RegistryWatcher coalesces anyway and the
// observer snapshot-diffs, so collapsing bursts loses nothing.
var timer = _debounceTimer;
if (timer == null)
{
var created = new Timer(_ => SafeTick("registry_change"), null, DebounceDelay, Timeout.InfiniteTimeSpan);
if (Interlocked.CompareExchange(ref _debounceTimer, created, null) != null)
created.Dispose();
else
return;
timer = _debounceTimer;
}
try { timer?.Change(DebounceDelay, Timeout.InfiniteTimeSpan); }
catch (ObjectDisposedException) { /* stopping */ }
}
private void SafeTick(string reason)
{
if (Volatile.Read(ref _stopped) != 0) return;
try
{
_observer.Tick(reason);
}
catch (Exception ex)
{
// Observer.Tick is itself fail-soft; this is the last-resort belt.
_logger?.Debug($"ImeRegistryAppState tick failed: {ex.Message}");
}
}
public void Stop()
{
if (Interlocked.Exchange(ref _stopped, 1) != 0) return;
// Never Stop() the watcher from inside its own callback — we're outside here,
// but RequestStop first keeps the contract obvious.
try { _watcher?.RequestStop(); } catch { /* fail-soft */ }
try { _watcher?.Dispose(); } catch { /* fail-soft */ }
_watcher = null;
_debounceTimer?.Dispose();
_debounceTimer = null;
_periodicTimer?.Dispose();
_periodicTimer = null;
}
public void Dispose() => Stop();
}
}
@@ -147,6 +147,8 @@ export const EVENT_TYPES_CATALOG = {
'agent_version_check',
'ime_agent_version',
'ime_token_failure',
'registry_app_state',
'app_state_reconciliation',
'ime_user_session_completed',
'ime_session_change',
'ime_process_exited',
@@ -417,6 +417,11 @@ namespace AutopilotMonitor.Shared
public const string EspPhaseChanged = "esp_phase_changed";
public const string ImeAgentVersion = "ime_agent_version";
public const string ImeTokenFailure = "ime_token_failure"; // Warning, once per distinct errorCode: IME token acquisition failed (IntuneTokenManager "Failed to get AAD token ... errorCode = N"). Complements IME-TOKEN-SUCCESS: an auth outage previously left every check-in silently doing nothing with no timeline evidence.
// Registry second pillar (ImeRegistryAppStateObserver, audit 2026-08-17): IME's authoritative
// per-app state read straight from HKLM\...\IntuneManagementExtension. Observability only —
// never consumed by the DecisionEngine.
public const string RegistryAppState = "registry_app_state"; // Info, state-change-only (snapshot-diff vs silent startup baseline; capped 200/session, cap announced once as Warning): per-app EnforcementState/ErrorCode/ExitCode/StatusService status/ESP-tracking from the registry.
public const string AppStateReconciliation = "app_state_reconciliation"; // Warning, once per app, immediate upload: registry terminal outcome (settled >= 90 s) diverges from the IME-log-derived app state — the built-in log-pattern drift alarm (registry = truth, logs = narrative).
public const string ImeUserSessionCompleted = "ime_user_session_completed";
public const string DoTelemetry = "do_telemetry";
public const string AllAppsCompleted = "all_apps_completed";
@@ -488,6 +488,7 @@ export const SHARED_MANIFEST = {
"app_install_skipped",
"app_install_started",
"app_install_starved",
"app_state_reconciliation",
"app_tracking_summary",
"autologon_analysis",
"autopilot_profile",
@@ -603,6 +604,7 @@ export const SHARED_MANIFEST = {
"realmjoin_resolved",
"realmjoin_timeout",
"reboot_triggered",
"registry_app_state",
"remote_config_fetch_failed",
"script_completed",
"script_failed",
@@ -484,6 +484,7 @@
"app_install_skipped",
"app_install_started",
"app_install_starved",
"app_state_reconciliation",
"app_tracking_summary",
"autologon_analysis",
"autopilot_profile",
@@ -599,6 +600,7 @@
"realmjoin_resolved",
"realmjoin_timeout",
"reboot_triggered",
"registry_app_state",
"remote_config_fetch_failed",
"script_completed",
"script_failed",