feat(agent): registry observer surfaces IME install-deadline waits and app-in-use deferrals (D-271)

ImeRegistryAppStateObserver reads a fourth IME registry surface,
Win32Apps\OperationalState\<userGuid>\<appGuid>: the install deadline a
downloaded app waits for (ExecutionDeadlineTime, IME 1.106) and the
app-in-use deferral counters (DeferralsUsed, DeferUntilTime,
DeferralMaxDeferrals, DeferralAutoDeferred, IME 1.105). Both travel on the
existing registry_app_state event as data (installDeadlineUtc, deferralsUsed,
deferralMaxDeferrals, deferUntilUtc, deferralAutoDeferred) with the change
triggers installDeadline / deferralsUsed / deferUntil, and the message says
the state in words ("downloaded, install deadline ... UTC", "deferred by user
1/3 until ... UTC", "auto-deferred ..."). No new event type.

ClassifyStatusServiceStatus maps IME's legacy AppInstallStatus explicitly:
1000/1002/1004 installed, 1001/1003 installing, 2000 band notApplicable,
3000 failed, anything else unjudged. The 1000 band was treated as installed,
but IME maps every InProgress enforcement state to 1001, so a running app
counted as terminal success on the registry rail (observability only).

Tests: explicit status mapping, both registry timestamp shapes, the new
change triggers, the wait-state text, and the tick flow for a deadline and a
deferral event. Mutation proof: classifier and trigger broken on purpose
turn exactly the five expected tests red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-23 09:46:07 +02:00
parent 37e920237c
commit f06fe5afc0
2 changed files with 263 additions and 8 deletions
@@ -59,9 +59,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
public void ClassifyEnforcementState_bands(int state, string expected)
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyEnforcementState(state));
// IME AppInstallStatus values (StatusServiceReports\..\Status), stable IME 1.50 .. 1.106.
// 1001/1003 sit inside the 1000 band but are NOT terminal — IME maps every InProgress
// enforcement state to 1001 (verified in the decompiled 1.106 StatusServiceHelpers).
[Theory]
[InlineData(1000, "installed")]
[InlineData(1999, "installed")]
[InlineData(1002, "installed")]
[InlineData(1004, "installed")]
[InlineData(1001, "installing")]
[InlineData(1003, "installing")]
[InlineData(1005, null)]
[InlineData(1999, null)]
[InlineData(3000, "failed")]
[InlineData(2000, "notApplicable")]
[InlineData(2999, "notApplicable")]
@@ -72,6 +80,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
public void ClassifyStatusServiceStatus_bands(int status, string? expected)
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyStatusServiceStatus(status));
[Fact]
public void TryParseRegistryDateTime_reads_both_ime_shapes_as_utc()
{
// ExecutionDeadlineTime: DateTime.ToString(InvariantCulture), UTC without a kind marker.
var deadline = ImeRegistryAppStateObserver.TryParseRegistryDateTime("09/23/2026 18:00:00");
Assert.Equal(new DateTime(2026, 9, 23, 18, 0, 0, DateTimeKind.Utc), deadline);
Assert.Equal(DateTimeKind.Utc, deadline!.Value.Kind);
// DeferUntilTime: round-trip "o" with trailing Z.
var until = ImeRegistryAppStateObserver.TryParseRegistryDateTime("2026-09-23T10:30:00.0000000Z");
Assert.Equal(new DateTime(2026, 9, 23, 10, 30, 0, DateTimeKind.Utc), until);
Assert.Equal(DateTimeKind.Utc, until!.Value.Kind);
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime(null));
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime(""));
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime("not a date"));
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime(42));
}
[Fact]
public void TryReadBool_parses_registry_strings_only()
{
Assert.True(ImeRegistryAppStateObserver.TryReadBool("True"));
Assert.False(ImeRegistryAppStateObserver.TryReadBool("false"));
Assert.Null(ImeRegistryAppStateObserver.TryReadBool("yes"));
Assert.Null(ImeRegistryAppStateObserver.TryReadBool(1));
Assert.Null(ImeRegistryAppStateObserver.TryReadBool(null));
}
[Fact]
public void TerminalOutcome_prefers_enforcement_state_then_status_service()
{
@@ -88,6 +125,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 3000)));
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 2500)));
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry()));
// Installing (1001) / InstallingPendingReboot (1003) share the 1000 band but are not terminal.
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 1001)));
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 2009, statusServiceStatus: 1003)));
}
[Fact]
public void ChangedFields_reports_wait_state_triggers_only()
{
var prev = Entry(enforcementState: 2009);
var next = Entry(enforcementState: 2009,
installDeadlineUtc: T0.AddHours(2),
deferralsUsed: 1, deferralMaxDeferrals: 3, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: false);
Assert.Equal(new[] { "installDeadline", "deferralsUsed", "deferUntil" },
ImeRegistryAppStateObserver.ChangedFields(prev, next));
// MaxDeferrals / AutoDeferred alone are data, not a transition.
var sameCounters = Entry(enforcementState: 2009,
installDeadlineUtc: T0.AddHours(2),
deferralsUsed: 1, deferralMaxDeferrals: 5, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: true);
Assert.Empty(ImeRegistryAppStateObserver.ChangedFields(next, sameCounters));
// A cleared deadline is a transition too (IME clears it when the next download starts).
Assert.Equal(new[] { "installDeadline" },
ImeRegistryAppStateObserver.ChangedFields(
Entry(installDeadlineUtc: T0.AddHours(2)), Entry()));
}
[Fact]
public void DescribeWaitState_texts()
{
Assert.Equal(string.Empty, ImeRegistryAppStateObserver.DescribeWaitState(Entry(enforcementState: 2009)));
Assert.Equal(", downloaded, install deadline 2026-08-18 10:00 UTC",
ImeRegistryAppStateObserver.DescribeWaitState(Entry(installDeadlineUtc: T0.AddHours(2))));
Assert.Equal(", deferred by user 1/3 until 2026-08-18 08:30 UTC",
ImeRegistryAppStateObserver.DescribeWaitState(Entry(
deferralsUsed: 1, deferralMaxDeferrals: 3, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: false)));
Assert.Equal(", auto-deferred 2/3",
ImeRegistryAppStateObserver.DescribeWaitState(Entry(
deferralsUsed: 2, deferralMaxDeferrals: 3, deferralAutoDeferred: true)));
// Zero deferrals used is not a deferral; a missing max falls back to the bare count.
Assert.Equal(string.Empty, ImeRegistryAppStateObserver.DescribeWaitState(Entry(deferralsUsed: 0, deferralMaxDeferrals: 3)));
Assert.Equal(", deferred by user 1", ImeRegistryAppStateObserver.DescribeWaitState(Entry(deferralsUsed: 1)));
}
[Fact]
@@ -184,6 +264,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.Equal("success", evt.Payload["enforcementClass"]);
}
[Fact]
public void Wait_state_changes_emit_state_events_with_data_and_text()
{
var (sink, clock, observer) = CreateObserver(trackerApps: null);
var current = Snapshot((App1, 2009, null));
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
{
observer.Tick("baseline");
// Download finished, IME parks the app until its install deadline (1.106 ExecutionDeadlineTime).
var withDeadline = Snapshot((App1, 2009, null));
withDeadline.Entries.Values.Single().InstallDeadlineUtc = T0.AddHours(2);
current = withDeadline;
observer.Tick("registry_change");
// A required app hits a running process and the user defers (1.105 app-in-use deferral).
var deferred = Snapshot((App1, 2014, null));
var entry = deferred.Entries.Values.Single();
entry.InstallDeadlineUtc = T0.AddHours(2);
entry.DeferralsUsed = 1;
entry.DeferralMaxDeferrals = 3;
entry.DeferUntilUtc = T0.AddMinutes(30);
entry.DeferralAutoDeferred = false;
current = deferred;
observer.Tick("registry_change");
}
var events = Events(sink, SharedEventTypes.RegistryAppState);
Assert.Equal(2, events.Count);
var deadlineEvent = events[0];
Assert.Equal("installDeadline", deadlineEvent.Payload!["changedFields"]);
Assert.Equal("2026-08-18T10:00:00.0000000Z", deadlineEvent.Payload["installDeadlineUtc"]);
Assert.DoesNotContain("deferralsUsed", deadlineEvent.Payload.Keys);
Assert.Contains("-> inProgress, downloaded, install deadline 2026-08-18 10:00 UTC",
deadlineEvent.Payload[SignalPayloadKeys.Message]);
var deferralEvent = events[1];
Assert.Equal("enforcementState,deferralsUsed,deferUntil", deferralEvent.Payload!["changedFields"]);
Assert.Equal("2014", deferralEvent.Payload["enforcementState"]);
Assert.Equal("1", deferralEvent.Payload["deferralsUsed"]);
Assert.Equal("3", deferralEvent.Payload["deferralMaxDeferrals"]);
Assert.Equal("2026-08-18T08:30:00.0000000Z", deferralEvent.Payload["deferUntilUtc"]);
Assert.Equal("false", deferralEvent.Payload["deferralAutoDeferred"]);
Assert.Contains("deferred by user 1/3 until 2026-08-18 08:30 UTC", deferralEvent.Payload[SignalPayloadKeys.Message]);
}
[Fact]
public void NonEmpty_baseline_emits_one_summary_with_counts()
{
@@ -331,7 +459,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
private static AppRegistryEntry Entry(
int? enforcementState = null, long? errorCode = null, int? exitCode = null,
int? statusServiceStatus = null, bool espTracked = false, string? espPhase = null)
int? statusServiceStatus = null, bool espTracked = false, string? espPhase = null,
DateTime? installDeadlineUtc = null, int? deferralsUsed = null, int? deferralMaxDeferrals = null,
DateTime? deferUntilUtc = null, bool? deferralAutoDeferred = null)
{
var entry = new AppRegistryEntry($"{ImeRegistrySnapshot.DeviceContext}|{App1}", ImeRegistrySnapshot.DeviceContext, App1);
entry.EnforcementState = enforcementState;
@@ -340,6 +470,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
entry.StatusServiceStatus = statusServiceStatus;
entry.EspTracked = espTracked;
entry.EspPhase = espPhase;
entry.InstallDeadlineUtc = installDeadlineUtc;
entry.DeferralsUsed = deferralsUsed;
entry.DeferralMaxDeferrals = deferralMaxDeferrals;
entry.DeferUntilUtc = deferUntilUtc;
entry.DeferralAutoDeferred = deferralAutoDeferred;
return entry;
}
@@ -24,10 +24,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// <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>
/// AppInstallStatusReport the ESP page renders (Status 1000=Installed, 1001=Installing,
/// 1003=InstallingPendingReboot, 2000=NotApplicable, 3000=Failed).</item>
/// <item><c>Win32Apps\OperationalState\&lt;userGuid&gt;\&lt;appGuid&gt;</c> (bare GUIDs) — IME's
/// per-app operational state: the install deadline a downloaded app is waiting for
/// (<c>ExecutionDeadlineTime</c>, IME 1.106+) and the app-in-use deferral counters
/// (<c>DeferralsUsed</c>, <c>DeferUntilTime</c>, ..., IME 1.105+).</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
/// coalesced — a per-write parse is impossible by design): every tick re-reads the four
/// 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.
@@ -162,6 +167,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
ReadWin32Apps(root, snapshot);
ReadEspTracking(root, snapshot);
ReadStatusServiceReports(root, snapshot);
ReadOperationalState(root, snapshot);
return snapshot;
}
@@ -250,6 +256,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
// Only the two wait states are read; the download/execution bookkeeping next to them
// (DownloadStatus, DownloadStartTime, ExecutionStatus, ...) duplicates the log narrative
// and would double the event volume without adding a state the timeline lacks.
private static void ReadOperationalState(RegistryKey root, ImeRegistrySnapshot snapshot)
{
using var operational = root.OpenSubKey(@"Win32Apps\OperationalState");
if (operational == null) return;
foreach (var userName in operational.GetSubKeyNames())
{
if (!LooksLikeGuid(userName)) continue;
using var userKey = operational.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 deadline = TryParseRegistryDateTime(appKey.GetValue("ExecutionDeadlineTime"));
var deferralsUsed = TryReadInt(appKey.GetValue("DeferralsUsed"));
if (deadline == null && deferralsUsed == null) continue; // no wait state — no entry of its own
var entry = snapshot.GetOrAdd(userName, appId);
entry.InstallDeadlineUtc = deadline;
entry.DeferralsUsed = deferralsUsed;
entry.DeferralMaxDeferrals = TryReadInt(appKey.GetValue("DeferralMaxDeferrals"));
entry.DeferUntilUtc = TryParseRegistryDateTime(appKey.GetValue("DeferUntilTime"));
entry.DeferralAutoDeferred = TryReadBool(appKey.GetValue("DeferralAutoDeferred"));
}
}
}
// ── pure helpers (unit-tested directly) ─────────────────────────────────
internal static bool LooksLikeGuid(string name) =>
@@ -301,6 +343,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
internal static bool? TryReadBool(object? value) =>
value is string s && bool.TryParse(s, out var parsed) ? parsed : (bool?)null;
/// <summary>
/// OperationalState timestamps are UTC but stored in two InvariantCulture shapes:
/// <c>ExecutionDeadlineTime</c> as <c>MM/dd/yyyy HH:mm:ss</c> without a kind marker,
/// <c>DeferUntilTime</c> round-trip ("o", trailing Z). Both come back as UTC here.
/// </summary>
internal static DateTime? TryParseRegistryDateTime(object? value)
{
if (!(value is string s) || string.IsNullOrWhiteSpace(s)) return null;
return DateTime.TryParse(s, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
? parsed
: (DateTime?)null;
}
/// <summary>StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104).</summary>
internal static string ClassifyEnforcementState(int state)
{
@@ -312,15 +371,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
return "unknown";
}
/// <summary>AppInstallStatus (StatusService/ESP page): 1000er = installed, 3000 = failed.</summary>
/// <summary>
/// AppInstallStatus as IME writes it to StatusServiceReports (unchanged IME 1.50 .. 1.106):
/// 1000 Installed, 1001 Installing, 1002 InstalledButDependenciesNotPresent,
/// 1003 InstallingPendingReboot, 1004 InstalledPendingReboot, 2000 NotApplicable, 3000 Failed.
/// The 1000 band is NOT uniformly terminal — IME maps every InProgress enforcement state to
/// 1001 — so the mapping is explicit and an unknown value is not judged.
/// </summary>
internal static string? ClassifyStatusServiceStatus(int status)
{
if (status >= 1000 && status < 2000) return "installed";
if (status == 3000) return "failed";
switch (status)
{
case 1000:
case 1002:
case 1004:
return "installed";
case 1001:
case 1003:
return "installing";
case 3000:
return "failed";
}
if (status >= 2000 && status < 3000) return "notApplicable";
return null;
}
/// <summary>
/// Human-readable suffix for the two IME wait states (install deadline, app-in-use
/// deferral); empty when neither applies. Culture-invariant on purpose — the agent runs
/// under whatever culture the SYSTEM account carries.
/// </summary>
internal static string DescribeWaitState(AppRegistryEntry entry)
{
var parts = new List<string>(2);
if (entry.InstallDeadlineUtc is DateTime deadline)
parts.Add("downloaded, install deadline " + FormatUtcMinute(deadline));
if (entry.DeferralsUsed is int used && used > 0)
{
var kind = entry.DeferralAutoDeferred == true ? "auto-deferred" : "deferred by user";
var count = entry.DeferralMaxDeferrals is int max
? used.ToString(CultureInfo.InvariantCulture) + "/" + max.ToString(CultureInfo.InvariantCulture)
: used.ToString(CultureInfo.InvariantCulture);
parts.Add(entry.DeferUntilUtc is DateTime until
? kind + " " + count + " until " + FormatUtcMinute(until)
: kind + " " + count);
}
return parts.Count == 0 ? string.Empty : ", " + string.Join(", ", parts);
}
private static string FormatUtcMinute(DateTime utc) =>
utc.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + " UTC";
internal static List<AppRegistryChange> DiffSnapshots(ImeRegistrySnapshot previous, ImeRegistrySnapshot next)
{
var changes = new List<AppRegistryChange>();
@@ -343,6 +446,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
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");
// Wait states: the deadline and each recorded deferral are transitions worth an event;
// MaxDeferrals / AutoDeferred only travel along as data.
if (prev?.InstallDeadlineUtc != next.InstallDeadlineUtc) changed.Add("installDeadline");
if (prev?.DeferralsUsed != next.DeferralsUsed) changed.Add("deferralsUsed");
if (prev?.DeferUntilUtc != next.DeferUntilUtc) changed.Add("deferUntil");
return changed;
}
@@ -445,6 +553,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
if (e.EspTracked) data["espTracked"] = "true";
if (!string.IsNullOrEmpty(e.EspPhase)) data["espPhase"] = e.EspPhase!;
if (e.InstallDeadlineUtc is DateTime deadline) data["installDeadlineUtc"] = deadline.ToString("o", CultureInfo.InvariantCulture);
if (e.DeferralsUsed is int used) data["deferralsUsed"] = used.ToString(CultureInfo.InvariantCulture);
if (e.DeferralMaxDeferrals is int max) data["deferralMaxDeferrals"] = max.ToString(CultureInfo.InvariantCulture);
if (e.DeferUntilUtc is DateTime until) data["deferUntilUtc"] = until.ToString("o", CultureInfo.InvariantCulture);
if (e.DeferralAutoDeferred is bool auto) data["deferralAutoDeferred"] = auto ? "true" : "false";
var summary = data.TryGetValue("enforcementClass", out var encls) ? encls
: data.TryGetValue("statusServiceClass", out var sscls) ? sscls
@@ -453,7 +566,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_post.Emit(
eventType: SharedEventTypes.RegistryAppState,
source: SourceLabel,
message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}",
message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}{DescribeWaitState(e)}",
severity: EventSeverity.Info,
data: data,
occurredAtUtc: nowUtc);
@@ -625,6 +738,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
public int? StatusServiceStatus { get; set; }
public bool EspTracked { get; set; }
public string? EspPhase { get; set; }
// Win32Apps\OperationalState — the two IME wait states (all UTC).
public DateTime? InstallDeadlineUtc { get; set; }
public int? DeferralsUsed { get; set; }
public int? DeferralMaxDeferrals { get; set; }
public DateTime? DeferUntilUtc { get; set; }
public bool? DeferralAutoDeferred { get; set; }
}
internal sealed class AppRegistryChange