mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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`
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+264
@@ -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();
|
||||
}
|
||||
}
|
||||
+577
@@ -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\<userGuid>\<appGuid>_<rev></c> — EnforcementStateMessage JSON
|
||||
/// (EnforcementState + ErrorCode), ExitCode, Intent. Device context = Guid.Empty user.</item>
|
||||
/// <item><c>EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev></c> — which apps IME
|
||||
/// registered for ESP tracking and in which phase.</item>
|
||||
/// <item><c>SideCarPolicies\StatusServiceReports\<userGuid>\<appId></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><appGuid>_<revision></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
|
||||
|
||||
+149
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user