diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerCompletenessTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerCompletenessTests.cs
new file mode 100644
index 00000000..98a3f2f5
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerCompletenessTests.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
+{
+ ///
+ /// Completeness tripwire for : the merger hand-maps
+ /// fields onto AgentConfiguration, and
+ /// historically new tenant knobs were forgotten there and silently ignored. Every public
+ /// property of must be classified in exactly one of the
+ /// two lists below. A new property fails this test until a human either wires it through
+ /// or documents where else it is consumed.
+ ///
+ public sealed class RemoteConfigMergerCompletenessTests
+ {
+ ///
+ /// Fields maps onto AgentConfiguration.
+ /// Keep in sync with the Merge body — the test only proves classification coverage,
+ /// the value-level mapping itself is covered by .
+ ///
+ private static readonly string[] MergedByMerger =
+ {
+ nameof(AgentConfigResponse.SelfDestructOnComplete),
+ nameof(AgentConfigResponse.KeepLogFile),
+ nameof(AgentConfigResponse.RebootOnComplete),
+ nameof(AgentConfigResponse.EnableGeoLocation),
+ nameof(AgentConfigResponse.LogLevel),
+ nameof(AgentConfigResponse.RebootDelaySeconds),
+ nameof(AgentConfigResponse.ShowEnrollmentSummary),
+ nameof(AgentConfigResponse.EnrollmentSummaryTimeoutSeconds),
+ nameof(AgentConfigResponse.EnrollmentSummaryBrandingImageUrl),
+ nameof(AgentConfigResponse.EnrollmentSummaryLaunchRetrySeconds),
+ nameof(AgentConfigResponse.NtpServer),
+ nameof(AgentConfigResponse.EnableTimezoneAutoSet),
+ nameof(AgentConfigResponse.DiagnosticsUploadEnabled),
+ nameof(AgentConfigResponse.DiagnosticsUploadMode),
+ nameof(AgentConfigResponse.DiagnosticsLogPaths),
+ nameof(AgentConfigResponse.SendTraceEvents),
+ nameof(AgentConfigResponse.EnableEspContinueAnywayObservation),
+ nameof(AgentConfigResponse.UnrestrictedMode),
+ nameof(AgentConfigResponse.EnableImeMatchLog),
+ nameof(AgentConfigResponse.EnableGatherRuleDebugLog),
+ nameof(AgentConfigResponse.UploadIntervalSeconds),
+ nameof(AgentConfigResponse.MaxBatchSize),
+ nameof(AgentConfigResponse.MaxAuthFailures),
+ nameof(AgentConfigResponse.AuthFailureTimeoutMinutes),
+ // Merge reads two Collectors knobs (AgentMaxLifetimeMinutes, HelloWaitTimeoutSeconds);
+ // the remaining CollectorConfiguration flags flow untouched to DefaultComponentFactory.
+ nameof(AgentConfigResponse.Collectors),
+ };
+
+ ///
+ /// Fields deliberately NOT mapped by the merger because another component consumes them
+ /// directly from the remote config. One-line justification per entry — verify the
+ /// consumer before classifying a new field here.
+ ///
+ private static readonly string[] IntentionallyNotMerged =
+ {
+ // Schema/version metadata — logged and cached by RemoteConfigService, not a runtime knob.
+ nameof(AgentConfigResponse.ConfigVersion),
+ // Consumed by DefaultComponentFactory → GatherRuleExecutorHost (rules run against the raw remote list).
+ nameof(AgentConfigResponse.GatherRules),
+ // Consumed by DefaultComponentFactory → ImeLogHost (pattern list handed to ImeLogTracker).
+ nameof(AgentConfigResponse.ImeLogPatterns),
+ // Passed by AgentRuntimeHost into IComponentFactory.CreateCollectorHosts → ImeLogHost sealing re-emit.
+ nameof(AgentConfigResponse.WhiteGloveSealingPatternIds),
+ // Consumed by DefaultComponentFactory (RealmJoin/KeepAwake/ConsoleBypass host gates) + AgentAnalyzerManager.
+ nameof(AgentConfigResponse.Analyzers),
+ // Self-update ZIP integrity second trust channel — BinaryIntegrityVerifier / Program update path.
+ nameof(AgentConfigResponse.LatestAgentSha256),
+ // Running-EXE integrity verification — BinaryIntegrityVerifier post-config check.
+ nameof(AgentConfigResponse.LatestAgentExeSha256),
+ // Self-updater force-update downgrade gate — BinaryIntegrityVerifier / Program update path.
+ nameof(AgentConfigResponse.AllowAgentDowngrade),
+ // Control-channel block signal — RemoteConfigService/ServerControlPlane; stripped from the disk cache.
+ nameof(AgentConfigResponse.DeviceBlocked),
+ // Control-channel kill signal — ServerControlPlane / Program.Guards; live fetch only.
+ nameof(AgentConfigResponse.DeviceKillSignal),
+ // Temporary-block expiry accompanying DeviceBlocked — ServerControlPlane.
+ nameof(AgentConfigResponse.UnblockAt),
+ // Endpoint migration — EndpointMigration/RemoteConfigService re-run the fetch, live fetch only.
+ nameof(AgentConfigResponse.MigrateToApiBaseUrl),
+ };
+
+ [Fact]
+ public void Every_AgentConfigResponse_property_is_classified_exactly_once()
+ {
+ var properties = typeof(AgentConfigResponse)
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Select(p => p.Name)
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToArray();
+
+ var merged = new HashSet(MergedByMerger, StringComparer.Ordinal);
+ var notMerged = new HashSet(IntentionallyNotMerged, StringComparer.Ordinal);
+
+ var inBoth = merged.Intersect(notMerged, StringComparer.Ordinal).ToArray();
+ Assert.True(
+ inBoth.Length == 0,
+ $"Fields listed in BOTH classification lists: [{string.Join(", ", inBoth)}].");
+
+ var unclassified = properties
+ .Where(p => !merged.Contains(p) && !notMerged.Contains(p))
+ .ToArray();
+ Assert.True(
+ unclassified.Length == 0,
+ "New AgentConfigResponse field(s) without a classification: " +
+ $"[{string.Join(", ", unclassified)}]. Either map them in RemoteConfigMerger.Merge " +
+ "(+ MergedByMerger here) or verify the consuming component and add them to " +
+ "IntentionallyNotMerged with a justification comment.");
+
+ // Stale entries (renamed/removed properties) must be cleaned up too.
+ var known = new HashSet(properties, StringComparer.Ordinal);
+ var stale = merged.Concat(notMerged).Where(n => !known.Contains(n)).ToArray();
+ Assert.True(
+ stale.Length == 0,
+ $"Classification lists reference non-existent AgentConfigResponse properties: [{string.Join(", ", stale)}].");
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DefaultComponentFactoryOrderingTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DefaultComponentFactoryOrderingTests.cs
new file mode 100644
index 00000000..70b94174
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DefaultComponentFactoryOrderingTests.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ ///
+ /// Enforces the comment-only ordering invariants of by
+ /// building the REAL host list (construction only — no host is started) and asserting the
+ /// relative positions of the order-sensitive host types. The orchestrator starts hosts in
+ /// list order, so list order IS start order:
+ ///
+ /// - DeviceInfoHost before EspAndHelloHost — L9 (delta review 2026-07-02): DeviceInfoHost
+ /// must subscribe to SignalPosted before EspAndHelloHost.Start can post the registry-backfill
+ /// EspPhaseChanged(DeviceSetup), or the enrollment-start re-collect never runs.
+ /// - OsBuildChangeHost before WindowsUpdateWatcherHost — the WU channel census reads
+ /// OsBuildChangeHost.BuildChanged at its own start.
+ /// - DeliveryOptimizationHost before OfficeInstallDetectorHost — the DO host must have
+ /// subscribed to the Office process-start signal before the watcher starts, or an install
+ /// already in flight misses its wake.
+ /// - DesktopArrivalHost before AadJoinHost — the AadJoinHost ctor takes the desktop
+ /// host's RequestResetForRealUserSwitch callback, so the target must exist first.
+ ///
+ ///
+ public sealed class DefaultComponentFactoryOrderingTests : IDisposable
+ {
+ private readonly TempDirectory _tmp = new TempDirectory();
+
+ public void Dispose() => _tmp.Dispose();
+
+ ///
+ /// Build the production host list with default remote config (all order-sensitive hosts
+ /// enabled by default) and system-boundary fakes only: temp state dir, fake ingress,
+ /// virtual clock, no network metrics, no telemetry spool.
+ ///
+ private CollectorSurfaces CreateSurfaces()
+ {
+ var factory = new DefaultComponentFactory(
+ agentConfig: new AgentConfiguration
+ {
+ ApiBaseUrl = "https://example",
+ TenantId = "t",
+ SessionId = "s",
+ },
+ remoteConfig: new AgentConfigResponse
+ {
+ Collectors = CollectorConfiguration.CreateDefault(),
+ },
+ networkMetrics: null,
+ agentVersion: "test",
+ stateDirectory: _tmp.Path,
+ startupEventGate: null);
+
+ return factory.CreateCollectorHosts(
+ sessionId: "s",
+ tenantId: "t",
+ logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
+ whiteGloveSealingPatternIds: Array.Empty(),
+ ingress: new FakeSignalIngressSink(),
+ clock: new VirtualClock(new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc)),
+ telemetrySpool: null,
+ timelineEvents: null);
+ }
+
+ private static int IndexOf(IReadOnlyList hosts) where THost : ICollectorHost
+ {
+ var indices = hosts
+ .Select((host, index) => (host, index))
+ .Where(t => t.host is THost)
+ .Select(t => t.index)
+ .ToArray();
+ Assert.True(
+ indices.Length == 1,
+ $"Expected exactly one {typeof(THost).Name} in the host list, found {indices.Length}. " +
+ $"Hosts: [{string.Join(", ", hosts.Select(h => h.GetType().Name))}]");
+ return indices[0];
+ }
+
+ private static void AssertBefore(IReadOnlyList hosts, string reason)
+ where TFirst : ICollectorHost
+ where TSecond : ICollectorHost
+ {
+ var first = IndexOf(hosts);
+ var second = IndexOf(hosts);
+ Assert.True(
+ first < second,
+ $"{typeof(TFirst).Name} (index {first}) must come before {typeof(TSecond).Name} (index {second}): {reason}");
+ }
+
+ [Fact]
+ public void Order_sensitive_hosts_keep_their_documented_relative_order()
+ {
+ var surfaces = CreateSurfaces();
+ var hosts = surfaces.Hosts;
+ try
+ {
+ AssertBefore(
+ hosts,
+ "L9 — DeviceInfoHost must subscribe to SignalPosted before EspAndHelloHost's " +
+ "registry backfill can post EspPhaseChanged(DeviceSetup).");
+
+ AssertBefore(
+ hosts,
+ "the Windows Update channel census reads OsBuildChangeHost.BuildChanged at its own start.");
+
+ AssertBefore(
+ hosts,
+ "the DO host must subscribe to the Office process-start signal before the watcher starts.");
+
+ AssertBefore(
+ hosts,
+ "AadJoinHost wires its onRealUserJoined callback to the already-constructed DesktopArrivalHost.");
+
+ // Closure-latch sanity (factory lines ~139/297): the EspAndHelloHost probes close
+ // over the ImeLogHost reference, which is assigned during the same factory call —
+ // the host must therefore exist in the returned list.
+ Assert.Equal(1, hosts.Count(h => h is ImeLogHost));
+ }
+ finally
+ {
+ foreach (var host in hosts)
+ {
+ try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SignalKindFixtureCoverageTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SignalKindFixtureCoverageTests.cs
new file mode 100644
index 00000000..bac9727e
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SignalKindFixtureCoverageTests.cs
@@ -0,0 +1,218 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using AutopilotMonitor.DecisionCore.Serialization;
+using AutopilotMonitor.DecisionCore.Signals;
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests
+{
+ ///
+ /// Enforces the fixture convention documented on :
+ /// "New kinds or version bumps require a replay fixture in
+ /// tests/fixtures/signal-kinds/{kind}-v{n}.json — missing fixture = merge block."
+ /// The comment alone enforced nothing; this test does. Kinds without a fixture today are
+ /// carried in an explicit debt set that is only allowed to shrink.
+ ///
+ public sealed class SignalKindFixtureCoverageTests
+ {
+ // DEBT: backfill — new kinds must NOT be added here. Every entry predates this test;
+ // when a fixture is added for one of these kinds, the test forces its removal.
+ private static readonly HashSet KnownMissingFixtures = new HashSet
+ {
+ DecisionSignalKind.EspPhaseChanged,
+ DecisionSignalKind.EspExiting,
+ DecisionSignalKind.EspResumed,
+ DecisionSignalKind.EspTerminalFailure,
+ DecisionSignalKind.DesktopArrived,
+ DecisionSignalKind.HelloResolved,
+ DecisionSignalKind.ImeUserSessionCompleted,
+ DecisionSignalKind.DeviceSetupProvisioningComplete,
+ DecisionSignalKind.WhiteGloveShellCoreSuccess,
+ DecisionSignalKind.WhiteGloveSealingPatternDetected,
+ DecisionSignalKind.AadUserJoinedLate,
+ DecisionSignalKind.DeviceInfoCollected,
+ DecisionSignalKind.AutopilotProfileRead,
+ DecisionSignalKind.EspConfigDetected,
+ DecisionSignalKind.HelloPolicyDetected,
+ DecisionSignalKind.ClassifierVerdictIssued,
+ DecisionSignalKind.SessionStarted,
+ DecisionSignalKind.SessionAborted,
+ };
+
+ ///
+ /// Kinds that exist in the enum but are never posted as their own signal: they are
+ /// dispatched over the rail (the reducer's
+ /// DeadlineFiredV1 switch routes on the deadline payload). Their fixture is named
+ /// after the kind, but its declared Kind is DeadlineFired — e.g.
+ /// realmjoin-timeout-v1.json carries Payload.deadline = "realmjoin_timeout".
+ ///
+ private static readonly HashSet DeadlineDispatchedKinds = new HashSet
+ {
+ DecisionSignalKind.RealmJoinTimeout,
+ };
+
+ /// Locate tests/fixtures/signal-kinds by walking up to the repo root (AutopilotMonitor.sln).
+ private static string FixtureRoot()
+ {
+ var dir = AppContext.BaseDirectory;
+ for (int i = 0; i < 12; i++)
+ {
+ if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln")))
+ {
+ return Path.Combine(dir, "tests", "fixtures", "signal-kinds");
+ }
+ var parent = Directory.GetParent(dir)?.FullName;
+ if (parent == null || parent == dir) break;
+ dir = parent;
+ }
+ throw new DirectoryNotFoundException(
+ "Could not locate repo root (AutopilotMonitor.sln) walking up from " + AppContext.BaseDirectory);
+ }
+
+ private static IReadOnlyList FixtureFiles()
+ {
+ var root = FixtureRoot();
+ Assert.True(Directory.Exists(root), $"Fixture directory not found: {root}");
+ var files = Directory.GetFiles(root, "*.json").OrderBy(f => f, StringComparer.Ordinal).ToArray();
+ Assert.NotEmpty(files);
+ return files;
+ }
+
+ /// Signal JSONL lines of one fixture ('#' comments and blank lines stripped).
+ private static IReadOnlyList SignalLines(string path)
+ {
+ var lines = File.ReadAllLines(path)
+ .Select(l => l.Trim())
+ .Where(l => l.Length > 0 && l[0] != '#')
+ .ToArray();
+ Assert.True(lines.Length > 0, $"Fixture contains no signal lines: {path}");
+ return lines;
+ }
+
+ ///
+ /// Strictly parse the raw Kind of a fixture line. Deliberately NOT via
+ /// alone: its unknown-kind fallback maps typos to
+ /// , which would silently mis-count coverage.
+ ///
+ private static DecisionSignalKind StrictKind(string line, string path)
+ {
+ var raw = JObject.Parse(line).Value("Kind");
+ Assert.False(string.IsNullOrEmpty(raw), $"Fixture line has no Kind: {path}");
+ Assert.True(
+ Enum.TryParse(raw, ignoreCase: false, out var kind)
+ && Enum.IsDefined(typeof(DecisionSignalKind), kind),
+ $"Fixture {Path.GetFileName(path)} declares unknown Kind '{raw}'.");
+ return kind;
+ }
+
+ /// Dash-insensitive lowercase — "realmjoin-detected-v1" and "RealmJoinDetected" both normalize consistently.
+ private static string Normalize(string value) => value.Replace("-", string.Empty).ToLowerInvariant();
+
+ ///
+ /// Map a fixture file name to the kind it is named after: the longest enum member whose
+ /// normalized name prefixes the normalized file name, followed by v{n}[-variant]
+ /// (dash placement per the existing corpus, e.g. realmjoin-detected-v1.json /
+ /// admin-preemption-detected-v1-failed.json).
+ ///
+ private static DecisionSignalKind KindFromFileName(string fileNameWithoutExtension)
+ {
+ var normalized = Normalize(fileNameWithoutExtension);
+ var match = ((DecisionSignalKind[])Enum.GetValues(typeof(DecisionSignalKind)))
+ .Where(k =>
+ {
+ var prefix = Normalize(k.ToString());
+ return normalized.StartsWith(prefix, StringComparison.Ordinal)
+ && Regex.IsMatch(normalized.Substring(prefix.Length), "^v[0-9]+");
+ })
+ .OrderByDescending(k => k.ToString().Length)
+ .Cast()
+ .FirstOrDefault();
+ Assert.True(
+ match.HasValue,
+ $"Fixture file name '{fileNameWithoutExtension}.json' does not follow " +
+ "'{kind}-v{n}[-variant].json' for any DecisionSignalKind.");
+ return match!.Value;
+ }
+
+ ///
+ /// Load every fixture through the REAL deserialization path and return the set of kinds
+ /// the corpus covers. Also enforces per-file consistency: the declared Kind must match
+ /// the file name (or be the DeadlineFired dispatch shape for deadline-dispatched kinds).
+ ///
+ private static ISet LoadAndVerifyFixtures()
+ {
+ var covered = new HashSet();
+ foreach (var file in FixtureFiles())
+ {
+ var fileName = Path.GetFileNameWithoutExtension(file);
+ var namedKind = KindFromFileName(fileName);
+
+ foreach (var line in SignalLines(file))
+ {
+ var declaredKind = StrictKind(line, file);
+
+ // Real deserialization path — the same call FixtureLoader/ReplayHarness use.
+ var signal = SignalSerializer.Deserialize(line);
+ Assert.Equal(declaredKind, signal.Kind);
+ Assert.True(signal.KindSchemaVersion >= 1, $"KindSchemaVersion must be >= 1 in {fileName}.");
+
+ if (declaredKind == namedKind)
+ {
+ covered.Add(declaredKind);
+ }
+ else
+ {
+ // Only the documented dispatch exception is allowed: a fixture named after
+ // a deadline-dispatched kind carries a DeadlineFired signal with the
+ // deadline name in its payload.
+ string? deadlineName = null;
+ signal.Payload?.TryGetValue("deadline", out deadlineName);
+ Assert.True(
+ DeadlineDispatchedKinds.Contains(namedKind)
+ && declaredKind == DecisionSignalKind.DeadlineFired
+ && !string.IsNullOrEmpty(deadlineName),
+ $"Fixture '{fileName}.json' is named after {namedKind} but declares Kind={declaredKind}. " +
+ "Either the file is misnamed, or a new deadline-dispatched kind must be " +
+ "registered in DeadlineDispatchedKinds (with a payload.deadline entry).");
+ covered.Add(namedKind);
+ covered.Add(declaredKind); // it IS also a genuine DeadlineFired fixture
+ }
+ }
+ }
+ return covered;
+ }
+
+ [Fact]
+ public void Every_fixture_deserializes_through_the_real_signal_path_and_matches_its_filename()
+ {
+ LoadAndVerifyFixtures();
+ }
+
+ [Fact]
+ public void Every_kind_is_either_fixtured_or_registered_as_known_debt()
+ {
+ var fixtured = LoadAndVerifyFixtures();
+ var allKinds = (DecisionSignalKind[])Enum.GetValues(typeof(DecisionSignalKind));
+
+ var unfixturedAndUntracked = allKinds
+ .Where(k => !fixtured.Contains(k) && !KnownMissingFixtures.Contains(k))
+ .ToArray();
+ Assert.True(
+ unfixturedAndUntracked.Length == 0,
+ "DecisionSignalKind values without a replay fixture in tests/fixtures/signal-kinds/: " +
+ $"[{string.Join(", ", unfixturedAndUntracked)}]. The convention on DecisionSignalKind is " +
+ "'new kind => fixture' — add a {kind}-v{n}.json fixture (do NOT extend KnownMissingFixtures).");
+
+ // Debt only shrinks: once a fixture exists, the kind must leave the debt set.
+ var fixturedButStillListedAsDebt = KnownMissingFixtures.Where(fixtured.Contains).ToArray();
+ Assert.True(
+ fixturedButStillListedAsDebt.Length == 0,
+ "Kinds now have fixtures but are still listed in KnownMissingFixtures — remove them: " +
+ $"[{string.Join(", ", fixturedButStillListedAsDebt)}].");
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/DecisionStateBuilderCompletenessTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/DecisionStateBuilderCompletenessTests.cs
new file mode 100644
index 00000000..1e85d56c
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/DecisionStateBuilderCompletenessTests.cs
@@ -0,0 +1,220 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using AutopilotMonitor.DecisionCore.Serialization;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests.State
+{
+ ///
+ /// Drift tripwire for the 4-site sync required when adding a field to
+ /// : ctor parameter +
+ /// copy-ctor line + builder property + Build() argument. A forgotten copy-ctor
+ /// line silently resets the fact on every reducer step; a forgotten Build()
+ /// argument resets it on the first copy-with-changes. These tests are fully
+ /// reflection-driven so a new field fails them until all four sites are wired
+ /// (and until a value factory for any brand-new property type is added below).
+ ///
+ public sealed class DecisionStateBuilderCompletenessTests
+ {
+ private static readonly DateTime BaseUtc = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
+
+ private static PropertyInfo[] StateProperties() =>
+ typeof(DecisionState)
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .OrderBy(p => p.Name, StringComparer.Ordinal)
+ .ToArray();
+
+ private static PropertyInfo[] BuilderProperties() =>
+ typeof(DecisionStateBuilder)
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(p => p.SetMethod != null && p.SetMethod.IsPublic)
+ .OrderBy(p => p.Name, StringComparer.Ordinal)
+ .ToArray();
+
+ ///
+ /// Produce a distinct, non-default value for a builder property.
+ /// is folded into every value so no two properties of the same type ever share an
+ /// instance — a swapped Build() argument (e.g. espFinalExitUtc passed into
+ /// desktopArrivedUtc) is therefore detected, not masked. A property type without a
+ /// factory entry fails loudly: extend the factory when a new field type appears.
+ ///
+ private static object CreateDistinctValue(Type type, int seed)
+ {
+ long ordinal = 100 + seed;
+ if (type == typeof(string)) return "test-value-" + seed;
+ if (type == typeof(int)) return 7 + seed;
+ if (type == typeof(long)) return 7L + seed;
+ if (type == typeof(DateTime?)) return BaseUtc.AddMinutes(seed);
+ if (type == typeof(SessionStage)) return SessionStage.AwaitingHello;
+ if (type == typeof(SessionOutcome?)) return SessionOutcome.EnrollmentComplete;
+ if (type == typeof(SignalFact)) return new SignalFact(BaseUtc.AddMinutes(seed), ordinal);
+ if (type == typeof(SignalFact)) return new SignalFact("test-value-" + seed, ordinal);
+ if (type == typeof(SignalFact)) return new SignalFact(true, ordinal);
+ if (type == typeof(SignalFact)) return new SignalFact(EnrollmentPhase.AccountSetup, ordinal);
+ if (type == typeof(List))
+ {
+ return new List
+ {
+ new ActiveDeadline(
+ name: "deadline-" + seed,
+ dueAtUtc: BaseUtc.AddMinutes(seed),
+ firesSignalKind: DecisionSignalKind.DeadlineFired,
+ firesPayload: new Dictionary { ["k"] = "v" + seed }),
+ };
+ }
+ // Immutable fact aggregates — built through their public APIs so the instances are
+ // reference-distinct from their Empty defaults AND fully serializable (a dropped
+ // copy-ctor line or Build() argument falls back to Empty → reference inequality).
+ if (type == typeof(AppInstallFacts)) return AppInstallFacts.Empty.WithCompleted("Installed").WithFailed("app-" + seed);
+ if (type == typeof(EnrollmentScenarioProfile))
+ {
+ return EnrollmentScenarioProfile.Empty.With(
+ mode: EnrollmentMode.WhiteGlove,
+ confidence: ProfileConfidence.High,
+ evidenceOrdinal: ordinal,
+ reason: "test-value-" + seed);
+ }
+ if (type == typeof(EnrollmentScenarioObservations)) return EnrollmentScenarioObservations.Empty.WithSkipUserEsp(true, ordinal);
+ if (type == typeof(ClassifierOutcomes))
+ {
+ return ClassifierOutcomes.Empty.WithWhiteGloveSealing(new Hypothesis(
+ level: HypothesisLevel.Strong,
+ reason: "test-value-" + seed,
+ score: 50,
+ lastUpdatedUtc: BaseUtc.AddMinutes(seed),
+ lastClassifierVerdictId: "verdict-" + seed));
+ }
+ if (type == typeof(RealmJoinFacts)) return RealmJoinFacts.Empty.WithDetected(BaseUtc.AddMinutes(seed), ordinal);
+
+ throw new Xunit.Sdk.XunitException(
+ $"No value factory for builder property type {type}. A new DecisionState field " +
+ "introduced a new type — add a distinct-value factory entry so the completeness " +
+ "tests can populate it.");
+ }
+
+ /// Populate every settable builder property with a distinct non-default value.
+ private static Dictionary PopulateBuilder(DecisionStateBuilder builder)
+ {
+ var assigned = new Dictionary(StringComparer.Ordinal);
+ int seed = 0;
+ foreach (var prop in BuilderProperties())
+ {
+ var value = CreateDistinctValue(prop.PropertyType, seed++);
+ prop.SetValue(builder, value);
+ assigned[prop.Name] = value;
+ }
+ return assigned;
+ }
+
+ private static void AssertMatches(string propertyName, object expected, object? actual)
+ {
+ // Deadlines: Build() copies the List into an array and the copy-ctor re-wraps it —
+ // the container is a new instance by design, but the elements must be the SAME refs.
+ if (expected is List expectedDeadlines)
+ {
+ var actualDeadlines = Assert.IsAssignableFrom>(actual).ToList();
+ Assert.Equal(expectedDeadlines.Count, actualDeadlines.Count);
+ for (int i = 0; i < expectedDeadlines.Count; i++)
+ {
+ Assert.Same(expectedDeadlines[i], actualDeadlines[i]);
+ }
+ return;
+ }
+
+ // Value types (enums, int, long, DateTime?) and strings compare by value; every
+ // reference-typed fact must be the identical instance (a re-created equal-looking
+ // value would still hide a dropped pass-through).
+ if (expected is string || expected.GetType().IsValueType)
+ {
+ Assert.True(
+ expected.Equals(actual),
+ $"{propertyName}: expected {expected}, got {actual ?? "(null)"}.");
+ }
+ else
+ {
+ Assert.Same(expected, actual);
+ }
+ }
+
+ // ============================================================= (a) name-set parity
+
+ [Fact]
+ public void Builder_property_set_matches_DecisionState_property_set()
+ {
+ var stateNames = StateProperties().Select(p => p.Name).ToArray();
+ var builderNames = BuilderProperties().Select(p => p.Name).ToArray();
+
+ var missingOnBuilder = stateNames.Except(builderNames, StringComparer.Ordinal).ToArray();
+ var missingOnState = builderNames.Except(stateNames, StringComparer.Ordinal).ToArray();
+
+ Assert.True(
+ missingOnBuilder.Length == 0 && missingOnState.Length == 0,
+ "DecisionState and DecisionStateBuilder property sets drifted apart. " +
+ $"State-only: [{string.Join(", ", missingOnBuilder)}]; " +
+ $"Builder-only: [{string.Join(", ", missingOnState)}]. " +
+ "Adding a DecisionState field requires: ctor parameter, builder copy-ctor line, " +
+ "builder property, and Build() argument.");
+ }
+
+ // ============================================================= (b) Build() completeness
+
+ [Fact]
+ public void Build_carries_every_builder_property_onto_the_state()
+ {
+ var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder();
+ var assigned = PopulateBuilder(builder);
+
+ var state = builder.Build();
+
+ foreach (var stateProp in StateProperties())
+ {
+ Assert.True(
+ assigned.TryGetValue(stateProp.Name, out var expected),
+ $"DecisionState.{stateProp.Name} has no builder counterpart — see the name-set parity test.");
+ AssertMatches($"DecisionState.{stateProp.Name}", expected!, stateProp.GetValue(state));
+ }
+ }
+
+ // ============================================================= (c) copy-ctor completeness
+
+ [Fact]
+ public void ToBuilder_copies_every_property_back_from_the_state()
+ {
+ var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder();
+ var assigned = PopulateBuilder(builder);
+ var state = builder.Build();
+
+ var roundtrippedBuilder = state.ToBuilder();
+
+ foreach (var builderProp in BuilderProperties())
+ {
+ var expected = assigned[builderProp.Name];
+ AssertMatches($"DecisionStateBuilder.{builderProp.Name}", expected, builderProp.GetValue(roundtrippedBuilder));
+ }
+ }
+
+ // ============================================================= (d) serializer roundtrip
+
+ [Fact]
+ public void StateSerializer_roundtrip_of_fully_populated_state_is_lossless()
+ {
+ // Catches Newtonsoft ctor-binding mismatches: deserialization binds JSON properties
+ // onto the single public ctor by (case-insensitive) parameter name, so a renamed
+ // ctor parameter silently drops the field back to its default. Serializing the
+ // deserialized state must therefore reproduce the exact same JSON.
+ var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder();
+ PopulateBuilder(builder);
+ var state = builder.Build();
+
+ var json = StateSerializer.Serialize(state);
+ var roundtrippedJson = StateSerializer.Serialize(StateSerializer.Deserialize(json));
+
+ Assert.Equal(json, roundtrippedJson);
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs
new file mode 100644
index 00000000..f49d98e0
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs
@@ -0,0 +1,146 @@
+using System.Reflection;
+using System.Text.Json;
+using AutopilotMonitor.Functions.Functions.Rules;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Models.Notifications;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Cross-language parity anchor: reflects the wire-relevant catalogs out of
+/// AutopilotMonitor.Shared (models, enums, string vocabularies) into a canonical JSON
+/// manifest committed at src/Web/autopilot-monitor-web/utils/shared-manifests.json.
+/// The web's vitest suite and compile-time checks compare their hand-written TS mirrors
+/// against that manifest — so a C# change that would silently drift a TS mirror first
+/// fails HERE, and the TS side fails until its mirror follows.
+///
+/// Regenerate after intentional changes:
+/// AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests
+/// then run the web codegen: node scripts/generate-shared-manifest-types.js
+///
+public sealed class SharedManifestParityTests
+{
+ private const string ManifestRepoPath = "src/Web/autopilot-monitor-web/utils/shared-manifests.json";
+ private const string WriteEnvVar = "AM_WRITE_SHARED_MANIFESTS";
+
+ [Fact]
+ public void Committed_manifest_matches_shared_assembly()
+ {
+ var expected = BuildManifestJson();
+ var path = Path.Combine(FindRepoRoot(), ManifestRepoPath.Replace('/', Path.DirectorySeparatorChar));
+
+ if (Environment.GetEnvironmentVariable(WriteEnvVar) == "1")
+ {
+ File.WriteAllText(path, expected);
+ return;
+ }
+
+ Assert.True(File.Exists(path),
+ $"Manifest missing at {ManifestRepoPath}. Regenerate: {WriteEnvVar}=1 dotnet test --filter SharedManifestParityTests");
+
+ var committed = File.ReadAllText(path).Replace("\r\n", "\n");
+ Assert.True(string.Equals(expected, committed, StringComparison.Ordinal),
+ $"shared-manifests.json is out of date with AutopilotMonitor.Shared. " +
+ $"Regenerate: {WriteEnvVar}=1 dotnet test --filter SharedManifestParityTests " +
+ $"(then: node scripts/generate-shared-manifest-types.js in the web folder).");
+ }
+
+ // ── manifest construction ───────────────────────────────────────────────
+
+ private static string BuildManifestJson()
+ {
+ var manifest = new Dictionary
+ {
+ ["$comment"] = "GENERATED from AutopilotMonitor.Shared by SharedManifestParityTests — do not edit by hand. " +
+ "Regenerate: AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests, " +
+ "then node scripts/generate-shared-manifest-types.js.",
+ ["adminConfiguration"] = new Dictionary
+ {
+ ["fields"] = WireFieldNames(typeof(AdminConfiguration)),
+ },
+ ["tenantConfiguration"] = new Dictionary
+ {
+ ["fields"] = WireFieldNames(typeof(TenantConfiguration)),
+ },
+ ["sessionSummary"] = new Dictionary
+ {
+ ["fields"] = WireFieldsWithOptionality(typeof(SessionSummary)),
+ },
+ // Enum member order is load-bearing (append-only ordinals) — declaration order kept.
+ ["sessionStatuses"] = Enum.GetNames(typeof(SessionStatus)),
+ ["enrollmentPhases"] = EnumMap(),
+ ["eventSeverities"] = EnumMap(),
+ ["webhookProviderTypes"] = EnumMap(),
+ ["annotationLanes"] = AnnotationLanes.All,
+ ["annotationVerdicts"] = AnnotationVerdicts.All,
+ ["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)),
+ ["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)),
+ ["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)),
+ ["analyzeRuleSources"] = DryRunAnalyzeRuleFunction.KnownSources.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
+ ["analyzeRuleOperators"] = DryRunAnalyzeRuleFunction.KnownOperators.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
+ ["eventTypes"] = ConstStrings(typeof(Constants.EventTypes)).OrderBy(s => s, StringComparer.Ordinal).ToArray(),
+ };
+
+ var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ });
+ // Normalized to LF + trailing newline so the file is byte-stable across OSes.
+ return json.Replace("\r\n", "\n") + "\n";
+ }
+
+ /// Wire (camelCase) names of the public instance properties, declaration order.
+ private static string[] WireFieldNames(Type type)
+ => WireProperties(type).Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name)).ToArray();
+
+ ///
+ /// Wire names plus whether the field can be ABSENT from the JSON: nullable fields are
+ /// omitted under WhenWritingNull, so nullable ⇒ the TS mirror must type it optional.
+ ///
+ private static object[] WireFieldsWithOptionality(Type type)
+ {
+ var nullability = new NullabilityInfoContext();
+ return WireProperties(type)
+ .Select(p => (object)new Dictionary
+ {
+ ["name"] = JsonNamingPolicy.CamelCase.ConvertName(p.Name),
+ ["optional"] = IsNullable(p, nullability),
+ })
+ .ToArray();
+ }
+
+ private static IEnumerable WireProperties(Type type)
+ => type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(p => p.GetMethod != null && p.GetIndexParameters().Length == 0);
+
+ private static bool IsNullable(PropertyInfo p, NullabilityInfoContext ctx)
+ {
+ if (Nullable.GetUnderlyingType(p.PropertyType) != null) return true;
+ if (p.PropertyType.IsValueType) return false;
+ return ctx.Create(p).ReadState != NullabilityState.NotNull;
+ }
+
+ private static Dictionary EnumMap() where TEnum : struct, Enum
+ => Enum.GetValues()
+ .ToDictionary(v => v.ToString(), v => Convert.ToInt32(v));
+
+ private static string[] ConstStrings(Type constClass)
+ => constClass.GetFields(BindingFlags.Public | BindingFlags.Static)
+ .Where(f => f.IsLiteral && f.FieldType == typeof(string))
+ .Select(f => (string)f.GetRawConstantValue()!)
+ .ToArray();
+
+ private static string FindRepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
+ {
+ dir = dir.Parent;
+ }
+ Assert.NotNull(dir);
+ return dir!.FullName;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs
index 48c501b9..2188b80c 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs
@@ -131,13 +131,14 @@ namespace AutopilotMonitor.Functions.Functions.Rules
// switch is case-sensitive ("event_type", not "Event_Type"), and an unknown source/operator
// silently evaluates to false in production — for a dry-run that silence is exactly the
// confusion we want to catch up front.
- private static readonly HashSet KnownSources = new(StringComparer.Ordinal)
+ // internal: SharedManifestParityTests exports these into the cross-language manifest.
+ internal static readonly HashSet KnownSources = new(StringComparer.Ordinal)
{
"event_type", "event_data", "event_data_array", "event_count",
"phase_duration", "app_install_duration", "event_correlation",
};
- private static readonly HashSet KnownOperators = new(StringComparer.OrdinalIgnoreCase)
+ internal static readonly HashSet KnownOperators = new(StringComparer.OrdinalIgnoreCase)
{
"equals", "not_equals", "contains", "not_contains", "regex", "not_regex",
"gt", "lt", "gte", "lte", "exists", "not_exists",
diff --git a/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js b/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js
new file mode 100644
index 00000000..6efbb99f
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js
@@ -0,0 +1,48 @@
+/**
+ * Generate utils/shared-manifests.generated.ts from utils/shared-manifests.json.
+ *
+ * The JSON is produced by the backend reflection guard (SharedManifestParityTests,
+ * regenerated via AM_WRITE_SHARED_MANIFESTS=1) — this script only lifts it into an
+ * `as const` TS module so the manifest's entries exist as LITERAL types, enabling the
+ * compile-time drift checks in utils/sharedManifestChecks.ts.
+ *
+ * Run: node scripts/generate-shared-manifest-types.js
+ * Freshness is pinned by utils/__tests__/sharedManifestParity.test.ts.
+ */
+
+const fs = require("fs");
+const path = require("path");
+
+const WEB_ROOT = path.resolve(__dirname, "..");
+const SOURCE = path.join(WEB_ROOT, "utils", "shared-manifests.json");
+const DEST = path.join(WEB_ROOT, "utils", "shared-manifests.generated.ts");
+
+/** Pure builder so the vitest freshness check can reuse it. */
+function buildGeneratedSource(manifestJsonText) {
+ const manifest = JSON.parse(manifestJsonText);
+ return (
+ "// GENERATED from shared-manifests.json — do not edit by hand.\n" +
+ "// Regenerate: node scripts/generate-shared-manifest-types.js\n" +
+ "// (after AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests)\n" +
+ "\n" +
+ "export const SHARED_MANIFEST = " +
+ JSON.stringify(manifest, null, 2) +
+ " as const;\n"
+ );
+}
+
+function main() {
+ if (!fs.existsSync(SOURCE)) {
+ console.error(`[shared-manifests] Source not found: ${SOURCE}`);
+ process.exit(1);
+ }
+ const out = buildGeneratedSource(fs.readFileSync(SOURCE, "utf8"));
+ fs.writeFileSync(DEST, out, "utf8");
+ console.log(`[shared-manifests] Wrote ${path.relative(WEB_ROOT, DEST)}`);
+}
+
+if (require.main === module) {
+ main();
+}
+
+module.exports = { buildGeneratedSource };
diff --git a/src/Web/autopilot-monitor-web/utils/__tests__/sharedManifestParity.test.ts b/src/Web/autopilot-monitor-web/utils/__tests__/sharedManifestParity.test.ts
new file mode 100644
index 00000000..5f15a3b8
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/utils/__tests__/sharedManifestParity.test.ts
@@ -0,0 +1,115 @@
+/**
+ * Runtime parity between hand-written TS mirrors and the C#-generated shared manifest.
+ * The manifest is the single cross-language anchor: the backend reflection guard
+ * (SharedManifestParityTests) pins JSON ↔ C#, these tests pin TS ↔ JSON.
+ * Compile-time (key-level) checks live in utils/sharedManifestChecks.ts.
+ */
+import { describe, expect, it } from "vitest";
+import { createRequire } from "node:module";
+import fs from "node:fs";
+import path from "node:path";
+import { SHARED_MANIFEST } from "../shared-manifests.generated";
+import { SEVERITY_INT } from "../sessionExportUtils";
+import { isTerminalStatus } from "../sessionStatus";
+import { OPERATORS, PRECONDITION_OPERATORS, SOURCES } from "@/app/analyze-rules/types";
+import {
+ ANNOTATION_LANES,
+ ANNOTATION_VERDICTS,
+} from "@/app/sessions/components/sessionAnnotationLogic";
+import { V1_PHASE_NAMES, V2_PHASE_NAMES } from "@/app/sessions/utils/phaseConstants";
+import { KNOWN_EVENT_TYPES } from "@/app/gather-rules/eventTypes";
+
+const require = createRequire(import.meta.url);
+const { buildGeneratedSource } = require("../../scripts/generate-shared-manifest-types.js");
+
+const utilsDir = path.resolve(__dirname, "..");
+
+describe("shared-manifests.generated.ts freshness", () => {
+ it("matches a fresh run of the codegen over shared-manifests.json", () => {
+ const json = fs.readFileSync(path.join(utilsDir, "shared-manifests.json"), "utf8");
+ const committed = fs.readFileSync(
+ path.join(utilsDir, "shared-manifests.generated.ts"),
+ "utf8"
+ );
+ expect(committed.replace(/\r\n/g, "\n")).toBe(buildGeneratedSource(json));
+ });
+});
+
+describe("analyze-rule builder catalogs", () => {
+ it("SOURCES equals the backend's KnownSources", () => {
+ expect([...SOURCES].sort()).toEqual([...SHARED_MANIFEST.analyzeRuleSources].sort());
+ });
+
+ it("OPERATORS equals the backend's KnownOperators", () => {
+ expect([...OPERATORS].sort()).toEqual([...SHARED_MANIFEST.analyzeRuleOperators].sort());
+ });
+
+ it("PRECONDITION_OPERATORS is a subset of the backend's KnownOperators", () => {
+ const known = new Set(SHARED_MANIFEST.analyzeRuleOperators);
+ for (const op of PRECONDITION_OPERATORS) expect(known).toContain(op);
+ });
+});
+
+describe("annotation lanes and verdicts", () => {
+ it("lanes match", () => {
+ expect([...ANNOTATION_LANES]).toEqual([...SHARED_MANIFEST.annotationLanes]);
+ });
+
+ it("verdicts match", () => {
+ expect([...ANNOTATION_VERDICTS]).toEqual([...SHARED_MANIFEST.annotationVerdicts]);
+ });
+});
+
+describe("event severities", () => {
+ it("SEVERITY_INT mirrors the C# EventSeverity enum exactly (names and ordinals)", () => {
+ expect(SEVERITY_INT).toEqual(SHARED_MANIFEST.eventSeverities);
+ });
+});
+
+describe("enrollment phases", () => {
+ it("every numeric phase key used by the UI exists on the C# enum", () => {
+ // Display STRINGS are deliberately surface-specific (V1 vs V2 wording); the
+ // numeric ordinals are the wire contract.
+ const enumValues = new Set(Object.values(SHARED_MANIFEST.enrollmentPhases));
+ for (const key of [...Object.keys(V1_PHASE_NAMES), ...Object.keys(V2_PHASE_NAMES)]) {
+ expect(enumValues).toContain(Number(key));
+ }
+ });
+
+ it("every C# enum ordinal has a UI name in both version maps", () => {
+ for (const value of Object.values(SHARED_MANIFEST.enrollmentPhases)) {
+ expect(V1_PHASE_NAMES[value], `V1 name for phase ${value}`).toBeTruthy();
+ expect(V2_PHASE_NAMES[value], `V2 name for phase ${value}`).toBeTruthy();
+ }
+ });
+});
+
+describe("session statuses", () => {
+ it("the terminal trio exists on the C# enum and is classified terminal", () => {
+ for (const status of ["Succeeded", "Failed", "Incomplete"]) {
+ expect(SHARED_MANIFEST.sessionStatuses).toContain(status);
+ expect(isTerminalStatus(status)).toBe(true);
+ }
+ });
+
+ it("non-terminal enum members are not classified terminal", () => {
+ for (const status of SHARED_MANIFEST.sessionStatuses) {
+ if (["Succeeded", "Failed", "Incomplete"].includes(status)) continue;
+ expect(isTerminalStatus(status), `${status} must not be terminal`).toBe(false);
+ }
+ });
+});
+
+describe("gather-rule event-type autocomplete", () => {
+ it("contains no event type the agent/backend does not know", () => {
+ // Typo guard: every autocomplete entry must be a real Constants.EventTypes value.
+ // (Completeness in the other direction is a deliberate curation question — the
+ // catalog carries ~3x more types than the rule builder surfaces today.)
+ const known = new Set(SHARED_MANIFEST.eventTypes);
+ for (const entry of KNOWN_EVENT_TYPES) {
+ expect(known, `unknown event type '${entry.value}' in KNOWN_EVENT_TYPES`).toContain(
+ entry.value
+ );
+ }
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts
new file mode 100644
index 00000000..04889a06
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts
@@ -0,0 +1,643 @@
+// GENERATED from shared-manifests.json — do not edit by hand.
+// Regenerate: node scripts/generate-shared-manifest-types.js
+// (after AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests)
+
+export const SHARED_MANIFEST = {
+ "$comment": "GENERATED from AutopilotMonitor.Shared by SharedManifestParityTests — do not edit by hand. Regenerate: AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests, then node scripts/generate-shared-manifest-types.js.",
+ "adminConfiguration": {
+ "fields": [
+ "partitionKey",
+ "rowKey",
+ "lastUpdated",
+ "updatedBy",
+ "globalRateLimitRequestsPerMinute",
+ "userRateLimitRequestsPerMinute",
+ "globalAdminRateLimitRequestsPerMinute",
+ "planTierDefinitionsJson",
+ "platformStatsBlobSasUrl",
+ "collectorIdleTimeoutMinutes",
+ "desktopDetectorNoCandidateTimeoutMinutes",
+ "excessiveEventCountThreshold",
+ "excessiveEventAutoActionMode",
+ "excessiveEventAutoActionThreshold",
+ "excessiveEventAutoActionDurationHours",
+ "opsEventRetentionDays",
+ "slaNotificationCooldownHours",
+ "feedbackEnabled",
+ "feedbackMinTenantAgeDays",
+ "feedbackCooldownDays",
+ "diagnosticsGlobalLogPathsJson",
+ "maxDiagnosticsDownloadSizeMB",
+ "diagnosticsDownloadTimeoutSeconds",
+ "mcpAccessPolicy",
+ "nvdApiKey",
+ "opsAlertRulesJson",
+ "opsAlertTelegramEnabled",
+ "opsAlertTelegramChatId",
+ "opsAlertTeamsEnabled",
+ "opsAlertTeamsWebhookUrl",
+ "opsAlertSlackEnabled",
+ "opsAlertSlackWebhookUrl",
+ "latestAgentV2Version",
+ "latestAgentV2Sha256",
+ "latestAgentV2ExeSha256",
+ "latestBootstrapV2ScriptVersion",
+ "allowAgentDowngrade",
+ "agentMigrateApiBaseUrl",
+ "agentMigrateTenantOverridesJson",
+ "modernDeploymentHarmlessEventIdsJson",
+ "whiteGloveSealingPatternIdsJson",
+ "vulnerabilityCorrelationEnabled",
+ "enableIndexDualWrite",
+ "autoApproveNewTenants",
+ "selfServiceAppHomingEnabled",
+ "sessionDeletionKillSwitch",
+ "vulnerabilityDataLastSyncUtc",
+ "msrcLastSyncUtc"
+ ]
+ },
+ "tenantConfiguration": {
+ "fields": [
+ "tenantId",
+ "domainName",
+ "lastUpdated",
+ "updatedBy",
+ "onboardedBy",
+ "contactEmail",
+ "onboardedAt",
+ "homedAppClientId",
+ "lastAuthClientId",
+ "lastAuthClientIdSince",
+ "disabled",
+ "disabledReason",
+ "disabledUntil",
+ "customRateLimitRequestsPerMinute",
+ "customUserRateLimitRequestsPerMinute",
+ "planTier",
+ "trialExpiresUtc",
+ "trialStartedUtc",
+ "trialConsumed",
+ "trialGrantedBy",
+ "manufacturerWhitelist",
+ "modelWhitelist",
+ "validateAutopilotDevice",
+ "validateCorporateIdentifier",
+ "validateDeviceAssociation",
+ "validateCloudPcDevice",
+ "allowInsecureAgentRequests",
+ "dataRetentionDays",
+ "sessionTimeoutHours",
+ "sessionGraceHours",
+ "maxNdjsonPayloadSizeMB",
+ "enablePerformanceCollector",
+ "performanceCollectorIntervalSeconds",
+ "helloWaitTimeoutSeconds",
+ "maxAuthFailures",
+ "authFailureTimeoutMinutes",
+ "agentMaxLifetimeMinutes",
+ "absoluteMaxSessionHours",
+ "selfDestructOnComplete",
+ "keepLogFile",
+ "rebootOnComplete",
+ "rebootDelaySeconds",
+ "enableGeoLocation",
+ "ntpServer",
+ "enableTimezoneAutoSet",
+ "enableImeMatchLog",
+ "enableGatherRuleDebugLog",
+ "enableEspContinueAnywayObservation",
+ "logLevel",
+ "maxBatchSize",
+ "showEnrollmentSummary",
+ "enrollmentSummaryTimeoutSeconds",
+ "enrollmentSummaryBrandingImageUrl",
+ "enrollmentSummaryLaunchRetrySeconds",
+ "showScriptOutput",
+ "enableLocalAdminAnalyzer",
+ "enableSoftwareInventoryAnalyzer",
+ "enableIntegrityBypassAnalyzer",
+ "enableRealmJoinWatcher",
+ "keepAwakeDuringUserEsp",
+ "enableConsoleBypassDetection",
+ "localAdminAllowedAccountsJson",
+ "bootstrapTokenEnabled",
+ "unrestrictedModeEnabled",
+ "unrestrictedMode",
+ "entraAppRolesEnabled",
+ "diagnosticsLogPathsJson",
+ "diagnosticsBlobSasUrl",
+ "diagnosticsUploadMode",
+ "diagnosticsUploadDestination",
+ "sendTraceEvents",
+ "teamsWebhookUrl",
+ "teamsNotifyOnSuccess",
+ "teamsNotifyOnFailure",
+ "teamsNotifyOnStart",
+ "webhookProviderType",
+ "webhookUrl",
+ "webhookNotifyOnSuccess",
+ "webhookNotifyOnFailure",
+ "webhookNotifyOnHardwareRejection",
+ "webhookNotifyOnStart",
+ "webhookCustomHeadersJson",
+ "notificationChannelsJson",
+ "slaTargetSuccessRate",
+ "slaTargetMaxDurationMinutes",
+ "slaTargetAppInstallSuccessRate",
+ "slaNotifyOnSuccessRateBreach",
+ "slaSuccessRateNotifyThreshold",
+ "slaNotifyOnDurationBreach",
+ "slaNotifyOnAppInstallBreach",
+ "slaNotifyOnConsecutiveFailures",
+ "slaConsecutiveFailureThreshold"
+ ]
+ },
+ "sessionSummary": {
+ "fields": [
+ {
+ "name": "sessionId",
+ "optional": false
+ },
+ {
+ "name": "tenantId",
+ "optional": false
+ },
+ {
+ "name": "serialNumber",
+ "optional": false
+ },
+ {
+ "name": "deviceName",
+ "optional": false
+ },
+ {
+ "name": "manufacturer",
+ "optional": false
+ },
+ {
+ "name": "model",
+ "optional": false
+ },
+ {
+ "name": "startedAt",
+ "optional": false
+ },
+ {
+ "name": "completedAt",
+ "optional": true
+ },
+ {
+ "name": "currentPhase",
+ "optional": false
+ },
+ {
+ "name": "currentPhaseDetail",
+ "optional": false
+ },
+ {
+ "name": "status",
+ "optional": false
+ },
+ {
+ "name": "failureReason",
+ "optional": false
+ },
+ {
+ "name": "failureSource",
+ "optional": false
+ },
+ {
+ "name": "reconcileReason",
+ "optional": false
+ },
+ {
+ "name": "espSoftFailure",
+ "optional": false
+ },
+ {
+ "name": "completionSource",
+ "optional": false
+ },
+ {
+ "name": "adminMarkedAction",
+ "optional": true
+ },
+ {
+ "name": "validatedBy",
+ "optional": false
+ },
+ {
+ "name": "eventCount",
+ "optional": false
+ },
+ {
+ "name": "durationSeconds",
+ "optional": true
+ },
+ {
+ "name": "avgApiLatencyMs",
+ "optional": true
+ },
+ {
+ "name": "apiRequestCount",
+ "optional": true
+ },
+ {
+ "name": "connectionType",
+ "optional": true
+ },
+ {
+ "name": "enrollmentType",
+ "optional": false
+ },
+ {
+ "name": "diagnosticsBlobName",
+ "optional": false
+ },
+ {
+ "name": "diagnosticsBlobDestination",
+ "optional": true
+ },
+ {
+ "name": "lastEventAt",
+ "optional": true
+ },
+ {
+ "name": "isPreProvisioned",
+ "optional": false
+ },
+ {
+ "name": "resumedAt",
+ "optional": true
+ },
+ {
+ "name": "stalledAt",
+ "optional": true
+ },
+ {
+ "name": "isHybridJoin",
+ "optional": false
+ },
+ {
+ "name": "isSelfDeployingProfile",
+ "optional": false
+ },
+ {
+ "name": "isCloudPc",
+ "optional": false
+ },
+ {
+ "name": "osName",
+ "optional": false
+ },
+ {
+ "name": "osBuild",
+ "optional": false
+ },
+ {
+ "name": "osDisplayVersion",
+ "optional": false
+ },
+ {
+ "name": "osEdition",
+ "optional": false
+ },
+ {
+ "name": "osLanguage",
+ "optional": false
+ },
+ {
+ "name": "isUserDriven",
+ "optional": false
+ },
+ {
+ "name": "agentVersion",
+ "optional": false
+ },
+ {
+ "name": "imeAgentVersion",
+ "optional": false
+ },
+ {
+ "name": "geoCountry",
+ "optional": false
+ },
+ {
+ "name": "geoRegion",
+ "optional": false
+ },
+ {
+ "name": "geoCity",
+ "optional": false
+ },
+ {
+ "name": "geoLoc",
+ "optional": false
+ },
+ {
+ "name": "platformScriptCount",
+ "optional": false
+ },
+ {
+ "name": "remediationScriptCount",
+ "optional": false
+ },
+ {
+ "name": "rebootCount",
+ "optional": false
+ },
+ {
+ "name": "excessiveEventsAlerted",
+ "optional": false
+ },
+ {
+ "name": "excessiveEventsAutoActioned",
+ "optional": false
+ },
+ {
+ "name": "pendingActionsJson",
+ "optional": false
+ },
+ {
+ "name": "pendingActionsQueuedAt",
+ "optional": true
+ },
+ {
+ "name": "failureSnapshotJson",
+ "optional": false
+ },
+ {
+ "name": "deletionState",
+ "optional": false
+ },
+ {
+ "name": "pendingDeletionManifestId",
+ "optional": true
+ }
+ ]
+ },
+ "sessionStatuses": [
+ "InProgress",
+ "Pending",
+ "Stalled",
+ "Succeeded",
+ "Failed",
+ "Unknown",
+ "AwaitingUser",
+ "Incomplete"
+ ],
+ "enrollmentPhases": {
+ "Start": 0,
+ "DevicePreparation": 1,
+ "DeviceSetup": 2,
+ "AppsDevice": 3,
+ "AccountSetup": 4,
+ "AppsUser": 5,
+ "FinalizingSetup": 6,
+ "Complete": 7,
+ "Failed": 99,
+ "Unknown": -1
+ },
+ "eventSeverities": {
+ "Debug": 0,
+ "Info": 1,
+ "Warning": 2,
+ "Error": 3,
+ "Critical": 4,
+ "Trace": -1
+ },
+ "webhookProviderTypes": {
+ "None": 0,
+ "TeamsLegacyConnector": 1,
+ "TeamsWorkflowWebhook": 2,
+ "Slack": 10,
+ "GenericJson": 20
+ },
+ "annotationLanes": [
+ "operator",
+ "tenantadmin",
+ "globaladmin"
+ ],
+ "annotationVerdicts": [
+ "root_cause_confirmed",
+ "analysis_wrong",
+ "different_problem",
+ "inconclusive"
+ ],
+ "tenantRoles": [
+ "Admin",
+ "Operator",
+ "Viewer"
+ ],
+ "globalRoles": [
+ "GlobalAdmin",
+ "GlobalReader"
+ ],
+ "delegatedRoles": [
+ "DelegatedReader",
+ "DelegatedAdmin"
+ ],
+ "analyzeRuleSources": [
+ "app_install_duration",
+ "event_correlation",
+ "event_count",
+ "event_data",
+ "event_data_array",
+ "event_type",
+ "phase_duration"
+ ],
+ "analyzeRuleOperators": [
+ "contains",
+ "count_gte",
+ "count_per_group_gte",
+ "equals",
+ "exists",
+ "gt",
+ "gte",
+ "in",
+ "lt",
+ "lte",
+ "not_contains",
+ "not_equals",
+ "not_exists",
+ "not_in",
+ "not_regex",
+ "regex"
+ ],
+ "eventTypes": [
+ "aad_join_status",
+ "aad_placeholder_user_detected",
+ "aad_user_joined_observed",
+ "admin_marked_session",
+ "agent_emergency_break",
+ "agent_late_start",
+ "agent_metrics_collector_stopped",
+ "agent_metrics_snapshot",
+ "agent_shutdown",
+ "agent_shutting_down",
+ "agent_started",
+ "agent_trace",
+ "agent_unrestricted_mode_changed",
+ "agent_version_check",
+ "all_apps_completed",
+ "app_download_started",
+ "app_install_completed",
+ "app_install_failed",
+ "app_install_skipped",
+ "app_install_started",
+ "app_install_starved",
+ "app_tracking_summary",
+ "autologon_analysis",
+ "autopilot_profile",
+ "autopilot_profile_missing",
+ "bitlocker_status",
+ "boot_time",
+ "cert_validation",
+ "collector_degraded",
+ "completion_check",
+ "completion_waiting",
+ "configmgr_client_detected",
+ "console_prefetch_detected",
+ "decision_process_completion",
+ "desktop_arrived",
+ "desktop_detector_first_poll",
+ "desktop_detector_no_candidate",
+ "desktop_detector_started",
+ "device_location",
+ "diagnostics_collecting",
+ "diagnostics_upload_failed",
+ "diagnostics_uploaded",
+ "disk_space_low",
+ "dns_configuration",
+ "do_telemetry",
+ "download_progress",
+ "enrollment_complete",
+ "enrollment_failed",
+ "enrollment_summary_shown",
+ "enrollment_type_detected",
+ "enrollment_type_mismatch",
+ "error_detected",
+ "esp_apps_failure_correlation",
+ "esp_appx_failure_analysis",
+ "esp_config_detected",
+ "esp_exiting",
+ "esp_failure",
+ "esp_failure_advisory",
+ "esp_failure_advisory_resolved",
+ "esp_failure_recovered",
+ "esp_failure_retry_detected",
+ "esp_failure_settle_recovered",
+ "esp_failure_settle_started",
+ "esp_phase_changed",
+ "esp_policy_provider_stalled",
+ "esp_provisioning_raw",
+ "esp_provisioning_settle_started",
+ "esp_provisioning_status",
+ "esp_resumed",
+ "esp_state_change",
+ "esp_ui_state",
+ "gather_result",
+ "gather_rules_collection_completed",
+ "gather_rules_collection_started",
+ "hardware_spec",
+ "hello_completion_timeout",
+ "hello_pin_status",
+ "hello_policy_detected",
+ "hello_policy_detection_mismatch",
+ "hello_processing_started",
+ "hello_processing_stopped",
+ "hello_provisioning_blocked",
+ "hello_provisioning_completed",
+ "hello_provisioning_failed",
+ "hello_skipped",
+ "hello_wait_timeout",
+ "hello_wizard_started",
+ "historic_ime_replay_detected",
+ "hybrid_login_pending",
+ "ime_agent_version",
+ "ime_process_exited",
+ "ime_session_change",
+ "ime_user_session_completed",
+ "ingress_backpressure",
+ "integrity_bypass_analysis",
+ "keep_awake_engaged",
+ "keep_awake_released",
+ "local_admin_analysis",
+ "log_entry",
+ "mdm_policy_reboot_required",
+ "modern_deployment_error",
+ "modern_deployment_log",
+ "modern_deployment_warning",
+ "network_adapters",
+ "network_bandwidth_estimate",
+ "network_connectivity_check",
+ "network_interface_info",
+ "network_state_change",
+ "ntp_time_check",
+ "office_install_completed",
+ "office_install_failed",
+ "office_install_progress",
+ "office_install_started",
+ "office_preinstalled_detected",
+ "oobe_console_spawned",
+ "oobe_state_completed",
+ "os_build_changed",
+ "os_info",
+ "outbound_ip",
+ "performance_collector_stopped",
+ "performance_snapshot",
+ "phase_transition",
+ "power_state_check",
+ "previous_crash_detected",
+ "prior_run_died_with_state",
+ "provisioning_package_scan",
+ "proxy_configuration",
+ "realmjoin_detected",
+ "realmjoin_first_deployment_incomplete",
+ "realmjoin_package_completed",
+ "realmjoin_package_started",
+ "realmjoin_phase_changed",
+ "realmjoin_resolved",
+ "realmjoin_timeout",
+ "reboot_triggered",
+ "remote_config_fetch_failed",
+ "script_completed",
+ "script_failed",
+ "script_started",
+ "script_timeout_suspected",
+ "secureboot_status",
+ "security_audit",
+ "security_warning",
+ "server_action_executed",
+ "server_action_failed",
+ "server_action_received",
+ "session_parked_without_deadline",
+ "session_stalled",
+ "session_timeout",
+ "shadow_discrepancy",
+ "software_inventory_analysis",
+ "spool_pressure_detected",
+ "stall_probe_check",
+ "stall_probe_result",
+ "state_quarantine_recovered",
+ "system_reboot_detected",
+ "telemetry_upload_blocked",
+ "telemetry_upload_poisoned",
+ "timezone_auto_set",
+ "tpm_status",
+ "vulnerability_report",
+ "waiting_for_hello",
+ "whiteglove_classification",
+ "whiteglove_complete",
+ "whiteglove_part1_complete",
+ "whiteglove_resumed",
+ "whiteglove_started",
+ "wifi_signal_info",
+ "windows_update_channel_census",
+ "windows_update_failed",
+ "windows_update_history",
+ "windows_update_reboot_pending",
+ "windows_update_started",
+ "windows_update_succeeded"
+ ]
+} as const;
diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json
new file mode 100644
index 00000000..d0aeb77f
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json
@@ -0,0 +1,639 @@
+{
+ "$comment": "GENERATED from AutopilotMonitor.Shared by SharedManifestParityTests — do not edit by hand. Regenerate: AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests, then node scripts/generate-shared-manifest-types.js.",
+ "adminConfiguration": {
+ "fields": [
+ "partitionKey",
+ "rowKey",
+ "lastUpdated",
+ "updatedBy",
+ "globalRateLimitRequestsPerMinute",
+ "userRateLimitRequestsPerMinute",
+ "globalAdminRateLimitRequestsPerMinute",
+ "planTierDefinitionsJson",
+ "platformStatsBlobSasUrl",
+ "collectorIdleTimeoutMinutes",
+ "desktopDetectorNoCandidateTimeoutMinutes",
+ "excessiveEventCountThreshold",
+ "excessiveEventAutoActionMode",
+ "excessiveEventAutoActionThreshold",
+ "excessiveEventAutoActionDurationHours",
+ "opsEventRetentionDays",
+ "slaNotificationCooldownHours",
+ "feedbackEnabled",
+ "feedbackMinTenantAgeDays",
+ "feedbackCooldownDays",
+ "diagnosticsGlobalLogPathsJson",
+ "maxDiagnosticsDownloadSizeMB",
+ "diagnosticsDownloadTimeoutSeconds",
+ "mcpAccessPolicy",
+ "nvdApiKey",
+ "opsAlertRulesJson",
+ "opsAlertTelegramEnabled",
+ "opsAlertTelegramChatId",
+ "opsAlertTeamsEnabled",
+ "opsAlertTeamsWebhookUrl",
+ "opsAlertSlackEnabled",
+ "opsAlertSlackWebhookUrl",
+ "latestAgentV2Version",
+ "latestAgentV2Sha256",
+ "latestAgentV2ExeSha256",
+ "latestBootstrapV2ScriptVersion",
+ "allowAgentDowngrade",
+ "agentMigrateApiBaseUrl",
+ "agentMigrateTenantOverridesJson",
+ "modernDeploymentHarmlessEventIdsJson",
+ "whiteGloveSealingPatternIdsJson",
+ "vulnerabilityCorrelationEnabled",
+ "enableIndexDualWrite",
+ "autoApproveNewTenants",
+ "selfServiceAppHomingEnabled",
+ "sessionDeletionKillSwitch",
+ "vulnerabilityDataLastSyncUtc",
+ "msrcLastSyncUtc"
+ ]
+ },
+ "tenantConfiguration": {
+ "fields": [
+ "tenantId",
+ "domainName",
+ "lastUpdated",
+ "updatedBy",
+ "onboardedBy",
+ "contactEmail",
+ "onboardedAt",
+ "homedAppClientId",
+ "lastAuthClientId",
+ "lastAuthClientIdSince",
+ "disabled",
+ "disabledReason",
+ "disabledUntil",
+ "customRateLimitRequestsPerMinute",
+ "customUserRateLimitRequestsPerMinute",
+ "planTier",
+ "trialExpiresUtc",
+ "trialStartedUtc",
+ "trialConsumed",
+ "trialGrantedBy",
+ "manufacturerWhitelist",
+ "modelWhitelist",
+ "validateAutopilotDevice",
+ "validateCorporateIdentifier",
+ "validateDeviceAssociation",
+ "validateCloudPcDevice",
+ "allowInsecureAgentRequests",
+ "dataRetentionDays",
+ "sessionTimeoutHours",
+ "sessionGraceHours",
+ "maxNdjsonPayloadSizeMB",
+ "enablePerformanceCollector",
+ "performanceCollectorIntervalSeconds",
+ "helloWaitTimeoutSeconds",
+ "maxAuthFailures",
+ "authFailureTimeoutMinutes",
+ "agentMaxLifetimeMinutes",
+ "absoluteMaxSessionHours",
+ "selfDestructOnComplete",
+ "keepLogFile",
+ "rebootOnComplete",
+ "rebootDelaySeconds",
+ "enableGeoLocation",
+ "ntpServer",
+ "enableTimezoneAutoSet",
+ "enableImeMatchLog",
+ "enableGatherRuleDebugLog",
+ "enableEspContinueAnywayObservation",
+ "logLevel",
+ "maxBatchSize",
+ "showEnrollmentSummary",
+ "enrollmentSummaryTimeoutSeconds",
+ "enrollmentSummaryBrandingImageUrl",
+ "enrollmentSummaryLaunchRetrySeconds",
+ "showScriptOutput",
+ "enableLocalAdminAnalyzer",
+ "enableSoftwareInventoryAnalyzer",
+ "enableIntegrityBypassAnalyzer",
+ "enableRealmJoinWatcher",
+ "keepAwakeDuringUserEsp",
+ "enableConsoleBypassDetection",
+ "localAdminAllowedAccountsJson",
+ "bootstrapTokenEnabled",
+ "unrestrictedModeEnabled",
+ "unrestrictedMode",
+ "entraAppRolesEnabled",
+ "diagnosticsLogPathsJson",
+ "diagnosticsBlobSasUrl",
+ "diagnosticsUploadMode",
+ "diagnosticsUploadDestination",
+ "sendTraceEvents",
+ "teamsWebhookUrl",
+ "teamsNotifyOnSuccess",
+ "teamsNotifyOnFailure",
+ "teamsNotifyOnStart",
+ "webhookProviderType",
+ "webhookUrl",
+ "webhookNotifyOnSuccess",
+ "webhookNotifyOnFailure",
+ "webhookNotifyOnHardwareRejection",
+ "webhookNotifyOnStart",
+ "webhookCustomHeadersJson",
+ "notificationChannelsJson",
+ "slaTargetSuccessRate",
+ "slaTargetMaxDurationMinutes",
+ "slaTargetAppInstallSuccessRate",
+ "slaNotifyOnSuccessRateBreach",
+ "slaSuccessRateNotifyThreshold",
+ "slaNotifyOnDurationBreach",
+ "slaNotifyOnAppInstallBreach",
+ "slaNotifyOnConsecutiveFailures",
+ "slaConsecutiveFailureThreshold"
+ ]
+ },
+ "sessionSummary": {
+ "fields": [
+ {
+ "name": "sessionId",
+ "optional": false
+ },
+ {
+ "name": "tenantId",
+ "optional": false
+ },
+ {
+ "name": "serialNumber",
+ "optional": false
+ },
+ {
+ "name": "deviceName",
+ "optional": false
+ },
+ {
+ "name": "manufacturer",
+ "optional": false
+ },
+ {
+ "name": "model",
+ "optional": false
+ },
+ {
+ "name": "startedAt",
+ "optional": false
+ },
+ {
+ "name": "completedAt",
+ "optional": true
+ },
+ {
+ "name": "currentPhase",
+ "optional": false
+ },
+ {
+ "name": "currentPhaseDetail",
+ "optional": false
+ },
+ {
+ "name": "status",
+ "optional": false
+ },
+ {
+ "name": "failureReason",
+ "optional": false
+ },
+ {
+ "name": "failureSource",
+ "optional": false
+ },
+ {
+ "name": "reconcileReason",
+ "optional": false
+ },
+ {
+ "name": "espSoftFailure",
+ "optional": false
+ },
+ {
+ "name": "completionSource",
+ "optional": false
+ },
+ {
+ "name": "adminMarkedAction",
+ "optional": true
+ },
+ {
+ "name": "validatedBy",
+ "optional": false
+ },
+ {
+ "name": "eventCount",
+ "optional": false
+ },
+ {
+ "name": "durationSeconds",
+ "optional": true
+ },
+ {
+ "name": "avgApiLatencyMs",
+ "optional": true
+ },
+ {
+ "name": "apiRequestCount",
+ "optional": true
+ },
+ {
+ "name": "connectionType",
+ "optional": true
+ },
+ {
+ "name": "enrollmentType",
+ "optional": false
+ },
+ {
+ "name": "diagnosticsBlobName",
+ "optional": false
+ },
+ {
+ "name": "diagnosticsBlobDestination",
+ "optional": true
+ },
+ {
+ "name": "lastEventAt",
+ "optional": true
+ },
+ {
+ "name": "isPreProvisioned",
+ "optional": false
+ },
+ {
+ "name": "resumedAt",
+ "optional": true
+ },
+ {
+ "name": "stalledAt",
+ "optional": true
+ },
+ {
+ "name": "isHybridJoin",
+ "optional": false
+ },
+ {
+ "name": "isSelfDeployingProfile",
+ "optional": false
+ },
+ {
+ "name": "isCloudPc",
+ "optional": false
+ },
+ {
+ "name": "osName",
+ "optional": false
+ },
+ {
+ "name": "osBuild",
+ "optional": false
+ },
+ {
+ "name": "osDisplayVersion",
+ "optional": false
+ },
+ {
+ "name": "osEdition",
+ "optional": false
+ },
+ {
+ "name": "osLanguage",
+ "optional": false
+ },
+ {
+ "name": "isUserDriven",
+ "optional": false
+ },
+ {
+ "name": "agentVersion",
+ "optional": false
+ },
+ {
+ "name": "imeAgentVersion",
+ "optional": false
+ },
+ {
+ "name": "geoCountry",
+ "optional": false
+ },
+ {
+ "name": "geoRegion",
+ "optional": false
+ },
+ {
+ "name": "geoCity",
+ "optional": false
+ },
+ {
+ "name": "geoLoc",
+ "optional": false
+ },
+ {
+ "name": "platformScriptCount",
+ "optional": false
+ },
+ {
+ "name": "remediationScriptCount",
+ "optional": false
+ },
+ {
+ "name": "rebootCount",
+ "optional": false
+ },
+ {
+ "name": "excessiveEventsAlerted",
+ "optional": false
+ },
+ {
+ "name": "excessiveEventsAutoActioned",
+ "optional": false
+ },
+ {
+ "name": "pendingActionsJson",
+ "optional": false
+ },
+ {
+ "name": "pendingActionsQueuedAt",
+ "optional": true
+ },
+ {
+ "name": "failureSnapshotJson",
+ "optional": false
+ },
+ {
+ "name": "deletionState",
+ "optional": false
+ },
+ {
+ "name": "pendingDeletionManifestId",
+ "optional": true
+ }
+ ]
+ },
+ "sessionStatuses": [
+ "InProgress",
+ "Pending",
+ "Stalled",
+ "Succeeded",
+ "Failed",
+ "Unknown",
+ "AwaitingUser",
+ "Incomplete"
+ ],
+ "enrollmentPhases": {
+ "Start": 0,
+ "DevicePreparation": 1,
+ "DeviceSetup": 2,
+ "AppsDevice": 3,
+ "AccountSetup": 4,
+ "AppsUser": 5,
+ "FinalizingSetup": 6,
+ "Complete": 7,
+ "Failed": 99,
+ "Unknown": -1
+ },
+ "eventSeverities": {
+ "Debug": 0,
+ "Info": 1,
+ "Warning": 2,
+ "Error": 3,
+ "Critical": 4,
+ "Trace": -1
+ },
+ "webhookProviderTypes": {
+ "None": 0,
+ "TeamsLegacyConnector": 1,
+ "TeamsWorkflowWebhook": 2,
+ "Slack": 10,
+ "GenericJson": 20
+ },
+ "annotationLanes": [
+ "operator",
+ "tenantadmin",
+ "globaladmin"
+ ],
+ "annotationVerdicts": [
+ "root_cause_confirmed",
+ "analysis_wrong",
+ "different_problem",
+ "inconclusive"
+ ],
+ "tenantRoles": [
+ "Admin",
+ "Operator",
+ "Viewer"
+ ],
+ "globalRoles": [
+ "GlobalAdmin",
+ "GlobalReader"
+ ],
+ "delegatedRoles": [
+ "DelegatedReader",
+ "DelegatedAdmin"
+ ],
+ "analyzeRuleSources": [
+ "app_install_duration",
+ "event_correlation",
+ "event_count",
+ "event_data",
+ "event_data_array",
+ "event_type",
+ "phase_duration"
+ ],
+ "analyzeRuleOperators": [
+ "contains",
+ "count_gte",
+ "count_per_group_gte",
+ "equals",
+ "exists",
+ "gt",
+ "gte",
+ "in",
+ "lt",
+ "lte",
+ "not_contains",
+ "not_equals",
+ "not_exists",
+ "not_in",
+ "not_regex",
+ "regex"
+ ],
+ "eventTypes": [
+ "aad_join_status",
+ "aad_placeholder_user_detected",
+ "aad_user_joined_observed",
+ "admin_marked_session",
+ "agent_emergency_break",
+ "agent_late_start",
+ "agent_metrics_collector_stopped",
+ "agent_metrics_snapshot",
+ "agent_shutdown",
+ "agent_shutting_down",
+ "agent_started",
+ "agent_trace",
+ "agent_unrestricted_mode_changed",
+ "agent_version_check",
+ "all_apps_completed",
+ "app_download_started",
+ "app_install_completed",
+ "app_install_failed",
+ "app_install_skipped",
+ "app_install_started",
+ "app_install_starved",
+ "app_tracking_summary",
+ "autologon_analysis",
+ "autopilot_profile",
+ "autopilot_profile_missing",
+ "bitlocker_status",
+ "boot_time",
+ "cert_validation",
+ "collector_degraded",
+ "completion_check",
+ "completion_waiting",
+ "configmgr_client_detected",
+ "console_prefetch_detected",
+ "decision_process_completion",
+ "desktop_arrived",
+ "desktop_detector_first_poll",
+ "desktop_detector_no_candidate",
+ "desktop_detector_started",
+ "device_location",
+ "diagnostics_collecting",
+ "diagnostics_upload_failed",
+ "diagnostics_uploaded",
+ "disk_space_low",
+ "dns_configuration",
+ "do_telemetry",
+ "download_progress",
+ "enrollment_complete",
+ "enrollment_failed",
+ "enrollment_summary_shown",
+ "enrollment_type_detected",
+ "enrollment_type_mismatch",
+ "error_detected",
+ "esp_apps_failure_correlation",
+ "esp_appx_failure_analysis",
+ "esp_config_detected",
+ "esp_exiting",
+ "esp_failure",
+ "esp_failure_advisory",
+ "esp_failure_advisory_resolved",
+ "esp_failure_recovered",
+ "esp_failure_retry_detected",
+ "esp_failure_settle_recovered",
+ "esp_failure_settle_started",
+ "esp_phase_changed",
+ "esp_policy_provider_stalled",
+ "esp_provisioning_raw",
+ "esp_provisioning_settle_started",
+ "esp_provisioning_status",
+ "esp_resumed",
+ "esp_state_change",
+ "esp_ui_state",
+ "gather_result",
+ "gather_rules_collection_completed",
+ "gather_rules_collection_started",
+ "hardware_spec",
+ "hello_completion_timeout",
+ "hello_pin_status",
+ "hello_policy_detected",
+ "hello_policy_detection_mismatch",
+ "hello_processing_started",
+ "hello_processing_stopped",
+ "hello_provisioning_blocked",
+ "hello_provisioning_completed",
+ "hello_provisioning_failed",
+ "hello_skipped",
+ "hello_wait_timeout",
+ "hello_wizard_started",
+ "historic_ime_replay_detected",
+ "hybrid_login_pending",
+ "ime_agent_version",
+ "ime_process_exited",
+ "ime_session_change",
+ "ime_user_session_completed",
+ "ingress_backpressure",
+ "integrity_bypass_analysis",
+ "keep_awake_engaged",
+ "keep_awake_released",
+ "local_admin_analysis",
+ "log_entry",
+ "mdm_policy_reboot_required",
+ "modern_deployment_error",
+ "modern_deployment_log",
+ "modern_deployment_warning",
+ "network_adapters",
+ "network_bandwidth_estimate",
+ "network_connectivity_check",
+ "network_interface_info",
+ "network_state_change",
+ "ntp_time_check",
+ "office_install_completed",
+ "office_install_failed",
+ "office_install_progress",
+ "office_install_started",
+ "office_preinstalled_detected",
+ "oobe_console_spawned",
+ "oobe_state_completed",
+ "os_build_changed",
+ "os_info",
+ "outbound_ip",
+ "performance_collector_stopped",
+ "performance_snapshot",
+ "phase_transition",
+ "power_state_check",
+ "previous_crash_detected",
+ "prior_run_died_with_state",
+ "provisioning_package_scan",
+ "proxy_configuration",
+ "realmjoin_detected",
+ "realmjoin_first_deployment_incomplete",
+ "realmjoin_package_completed",
+ "realmjoin_package_started",
+ "realmjoin_phase_changed",
+ "realmjoin_resolved",
+ "realmjoin_timeout",
+ "reboot_triggered",
+ "remote_config_fetch_failed",
+ "script_completed",
+ "script_failed",
+ "script_started",
+ "script_timeout_suspected",
+ "secureboot_status",
+ "security_audit",
+ "security_warning",
+ "server_action_executed",
+ "server_action_failed",
+ "server_action_received",
+ "session_parked_without_deadline",
+ "session_stalled",
+ "session_timeout",
+ "shadow_discrepancy",
+ "software_inventory_analysis",
+ "spool_pressure_detected",
+ "stall_probe_check",
+ "stall_probe_result",
+ "state_quarantine_recovered",
+ "system_reboot_detected",
+ "telemetry_upload_blocked",
+ "telemetry_upload_poisoned",
+ "timezone_auto_set",
+ "tpm_status",
+ "vulnerability_report",
+ "waiting_for_hello",
+ "whiteglove_classification",
+ "whiteglove_complete",
+ "whiteglove_part1_complete",
+ "whiteglove_resumed",
+ "whiteglove_started",
+ "wifi_signal_info",
+ "windows_update_channel_census",
+ "windows_update_failed",
+ "windows_update_history",
+ "windows_update_reboot_pending",
+ "windows_update_started",
+ "windows_update_succeeded"
+ ]
+}
diff --git a/src/Web/autopilot-monitor-web/utils/sharedManifestChecks.ts b/src/Web/autopilot-monitor-web/utils/sharedManifestChecks.ts
new file mode 100644
index 00000000..c367a0ce
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/utils/sharedManifestChecks.ts
@@ -0,0 +1,28 @@
+/**
+ * COMPILE-TIME drift checks between hand-written TS mirrors and the C#-generated
+ * shared manifest (utils/shared-manifests.json → shared-manifests.generated.ts).
+ *
+ * A red squiggle / failing `tsc --noEmit` here means one side changed without the
+ * other: either add the field to the TS mirror, or regenerate the manifest
+ * (AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests,
+ * then node scripts/generate-shared-manifest-types.js).
+ *
+ * Runtime (value-level) parity lives in utils/__tests__/sharedManifestParity.test.ts.
+ */
+import type { AdminConfiguration } from "@/types/adminConfig";
+import { SHARED_MANIFEST } from "./shared-manifests.generated";
+
+/** Resolves only when T is empty — a non-empty union here IS the drift report. */
+type AssertNever = T;
+
+type AdminWireKey = (typeof SHARED_MANIFEST)["adminConfiguration"]["fields"][number];
+
+/** C# fields the TS interface is missing. Must stay `never`. */
+export type AdminConfigFieldsMissingInTs = AssertNever<
+ Exclude
+>;
+
+/** TS fields the C# model no longer has (ghost fields). Must stay `never`. */
+export type AdminConfigGhostFieldsInTs = AssertNever<
+ Exclude
+>;
diff --git a/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json b/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json
index fda41e44..606a5c97 100644
--- a/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json
+++ b/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json
@@ -5,4 +5,8 @@
# Reducer: HandleRealmJoinTimeoutDeadlineFired → sets RealmJoinFacts.Outcome="Timeout",
# emits a realmjoin_timeout timeline event, and (if Hello+Desktop already resolved OR
# SelfDeployingDeferredCompletion is set) routes through CompleteIfDeferredOrBookkeep.
-{"SessionSignalOrdinal":99,"SessionTraceOrdinal":99,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T11:00:00.0000000Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Derived","Identifier":"deadline-realmjoin-timeout","Summary":"RealmJoin hard timeout fired"},"Payload":{"deadline":"realmjoin_timeout"}}
+# Evidence is Synthetic — production DeadlineFired posts come from
+# EnrollmentOrchestrator.OnDeadlineFired with EvidenceKind.Synthetic (Derived evidence would
+# require DerivationInputs and fail SignalSerializer.Deserialize; caught by
+# SignalKindFixtureCoverageTests 2026-08-13 — this fixture previously could not replay at all).
+{"SessionSignalOrdinal":99,"SessionTraceOrdinal":99,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T11:00:00.0000000Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"realmjoin_timeout","Summary":"RealmJoin hard timeout fired"},"Payload":{"deadline":"realmjoin_timeout"}}