diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DoThrottlePolicyReaderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DoThrottlePolicyReaderTests.cs
new file mode 100644
index 00000000..d63d68cd
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DoThrottlePolicyReaderTests.cs
@@ -0,0 +1,103 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
+{
+ ///
+ /// DoThrottlePolicyReader — pins the two-store semantics (GPO and Intune/MDM land in
+ /// DIFFERENT registry paths and both must be read), the "0 = dynamic/no limit → not
+ /// configured" cap rule vs. "DownloadMode 0 is a real value", and fail-soft conversion.
+ ///
+ public sealed class DoThrottlePolicyReaderTests
+ {
+ private static Func Fake(Dictionary<(string, string), object?> values)
+ => (key, name) => values.TryGetValue((key, name), out var v) ? v : null;
+
+ [Fact]
+ public void Gpo_and_Mdm_are_read_from_their_own_stores()
+ {
+ var snapshot = DoThrottlePolicyReader.ReadCore(Fake(new Dictionary<(string, string), object?>
+ {
+ [(DoThrottlePolicyReader.GpoKeyPath, "DOMaxForegroundDownloadBandwidth")] = 2500, // KB/s via GPO
+ [(DoThrottlePolicyReader.MdmKeyPath, "DOPercentageMaxBackgroundBandwidth")] = 40, // % via Intune
+ }));
+
+ Assert.Equal(2500, snapshot.GpoMaxForegroundKBps);
+ Assert.Null(snapshot.MdmMaxForegroundKBps); // MDM store has no absolute cap
+ Assert.Equal(40, snapshot.MdmPctMaxBackground);
+ Assert.Null(snapshot.GpoPctMaxBackground); // GPO store has no percentage cap
+ Assert.True(snapshot.ThrottleConfigured);
+ Assert.Equal("gpo+mdm", snapshot.ThrottleSources);
+ }
+
+ [Fact]
+ public void Zero_caps_mean_dynamic_and_are_not_configured()
+ {
+ var snapshot = DoThrottlePolicyReader.ReadCore(Fake(new Dictionary<(string, string), object?>
+ {
+ [(DoThrottlePolicyReader.GpoKeyPath, "DOMaxForegroundDownloadBandwidth")] = 0,
+ [(DoThrottlePolicyReader.MdmKeyPath, "DOPercentageMaxForegroundBandwidth")] = 0,
+ }));
+
+ Assert.Null(snapshot.GpoMaxForegroundKBps);
+ Assert.Null(snapshot.MdmPctMaxForeground);
+ Assert.False(snapshot.ThrottleConfigured);
+ Assert.Null(snapshot.ThrottleSources);
+ }
+
+ [Fact]
+ public void DownloadMode_zero_is_a_real_value_but_not_a_throttle()
+ {
+ var snapshot = DoThrottlePolicyReader.ReadCore(Fake(new Dictionary<(string, string), object?>
+ {
+ [(DoThrottlePolicyReader.MdmKeyPath, "DODownloadMode")] = 0, // HTTP only — real setting
+ }));
+
+ Assert.Equal(0, snapshot.MdmDownloadMode);
+ Assert.False(snapshot.ThrottleConfigured); // DownloadMode alone is no bandwidth cap
+ Assert.Null(snapshot.ThrottleSources);
+ }
+
+ [Fact]
+ public void String_stored_numbers_are_tolerated()
+ {
+ // PolicyManager occasionally materializes int policies as strings.
+ var snapshot = DoThrottlePolicyReader.ReadCore(Fake(new Dictionary<(string, string), object?>
+ {
+ [(DoThrottlePolicyReader.MdmKeyPath, "DOMaxBackgroundDownloadBandwidth")] = "1200",
+ }));
+
+ Assert.Equal(1200, snapshot.MdmMaxBackgroundKBps);
+ Assert.Equal("mdm", snapshot.ThrottleSources);
+ }
+
+ [Fact]
+ public void Garbage_values_and_thrown_getters_are_fail_soft()
+ {
+ var garbage = DoThrottlePolicyReader.ReadCore(Fake(new Dictionary<(string, string), object?>
+ {
+ [(DoThrottlePolicyReader.GpoKeyPath, "DOMaxForegroundDownloadBandwidth")] = "not-a-number",
+ }));
+ Assert.Null(garbage.GpoMaxForegroundKBps);
+ Assert.False(garbage.ThrottleConfigured);
+
+ var throwing = DoThrottlePolicyReader.ReadCore((_, __) => throw new InvalidOperationException("registry broken"));
+ Assert.False(throwing.ThrottleConfigured);
+ Assert.Null(throwing.ThrottleSources);
+ }
+
+ [Fact]
+ public void Empty_registry_yields_empty_snapshot()
+ {
+ var snapshot = DoThrottlePolicyReader.ReadCore((_, __) => null);
+
+ Assert.False(snapshot.ThrottleConfigured);
+ Assert.Null(snapshot.ThrottleSources);
+ Assert.Null(snapshot.GpoDownloadMode);
+ Assert.Null(snapshot.MdmDownloadMode);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs
index 98f1c3e0..123ca360 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs
@@ -685,7 +685,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
data["lanMbpsMax"] = Math.Round(estimate.LanMbpsMax.Value, 1);
}
+ // DO throttle-policy context (read fresh — Intune policies land DURING the
+ // enrollment): lets an analysis distinguish "line-limited" (no caps → the p90
+ // is a real capacity lower bound) from "policy-limited" (caps present → the
+ // measured rate reflects the throttle, the line is likely faster). GPO and MDM
+ // are SEPARATE registry stores; both are reported when present.
+ var throttle = DoThrottlePolicyReader.Read();
+ data["doThrottleConfigured"] = throttle.ThrottleConfigured;
+ if (throttle.ThrottleSources != null) data["doThrottleSources"] = throttle.ThrottleSources;
+ if (throttle.GpoMaxForegroundKBps.HasValue) data["doPolicyGpoMaxForegroundKBps"] = throttle.GpoMaxForegroundKBps.Value;
+ if (throttle.GpoMaxBackgroundKBps.HasValue) data["doPolicyGpoMaxBackgroundKBps"] = throttle.GpoMaxBackgroundKBps.Value;
+ if (throttle.GpoPctMaxForeground.HasValue) data["doPolicyGpoPctMaxForeground"] = throttle.GpoPctMaxForeground.Value;
+ if (throttle.GpoPctMaxBackground.HasValue) data["doPolicyGpoPctMaxBackground"] = throttle.GpoPctMaxBackground.Value;
+ if (throttle.GpoDownloadMode.HasValue) data["doPolicyGpoDownloadMode"] = throttle.GpoDownloadMode.Value;
+ if (throttle.MdmMaxForegroundKBps.HasValue) data["doPolicyMdmMaxForegroundKBps"] = throttle.MdmMaxForegroundKBps.Value;
+ if (throttle.MdmMaxBackgroundKBps.HasValue) data["doPolicyMdmMaxBackgroundKBps"] = throttle.MdmMaxBackgroundKBps.Value;
+ if (throttle.MdmPctMaxForeground.HasValue) data["doPolicyMdmPctMaxForeground"] = throttle.MdmPctMaxForeground.Value;
+ if (throttle.MdmPctMaxBackground.HasValue) data["doPolicyMdmPctMaxBackground"] = throttle.MdmPctMaxBackground.Value;
+ if (throttle.MdmDownloadMode.HasValue) data["doPolicyMdmDownloadMode"] = throttle.MdmDownloadMode.Value;
+
var interimSuffix = trigger == "device_setup_end" ? " [interim, after DeviceSetup]" : "";
+ if (throttle.ThrottleConfigured) interimSuffix += " [DO throttled by policy]";
string message;
if (estimate.WanMbpsP90.HasValue)
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs
new file mode 100644
index 00000000..c87f6c77
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs
@@ -0,0 +1,145 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
+{
+ ///
+ /// Snapshot of the configured Delivery Optimization bandwidth-throttle policies from BOTH
+ /// policy stores. GPO and Intune/MDM do NOT land in the same registry path (verified against
+ /// the Policy CSP / DO reference docs, 2026-07):
+ ///
+ /// - GPO (ADMX): HKLM\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization,
+ /// DO-prefixed value names (DOMaxForegroundDownloadBandwidth, …).
+ /// - Intune / Policy CSP: HKLM\SOFTWARE\Microsoft\PolicyManager\current\device\DeliveryOptimization,
+ /// same value names.
+ ///
+ /// Absolute caps are in KB/s; percentage caps 0-100. A value of 0 means "dynamic, no limit"
+ /// (the OS default) — treated as not configured for the caps. DownloadMode 0 however is a
+ /// real setting (HTTP only) and is reported whenever the value exists.
+ ///
+ public sealed class DoThrottlePolicySnapshot
+ {
+ // GPO store (SOFTWARE\Policies\...)
+ public int? GpoMaxForegroundKBps { get; set; }
+ public int? GpoMaxBackgroundKBps { get; set; }
+ public int? GpoPctMaxForeground { get; set; }
+ public int? GpoPctMaxBackground { get; set; }
+ public int? GpoDownloadMode { get; set; }
+
+ // MDM / Policy CSP store (SOFTWARE\Microsoft\PolicyManager\current\device\...)
+ public int? MdmMaxForegroundKBps { get; set; }
+ public int? MdmMaxBackgroundKBps { get; set; }
+ public int? MdmPctMaxForeground { get; set; }
+ public int? MdmPctMaxBackground { get; set; }
+ public int? MdmDownloadMode { get; set; }
+
+ /// True when any download-bandwidth cap is configured in either store.
+ public bool ThrottleConfigured =>
+ GpoMaxForegroundKBps.HasValue || GpoMaxBackgroundKBps.HasValue ||
+ GpoPctMaxForeground.HasValue || GpoPctMaxBackground.HasValue ||
+ MdmMaxForegroundKBps.HasValue || MdmMaxBackgroundKBps.HasValue ||
+ MdmPctMaxForeground.HasValue || MdmPctMaxBackground.HasValue;
+
+ /// "gpo" | "mdm" | "gpo+mdm" — which store(s) carry throttle caps. Null when none.
+ public string? ThrottleSources
+ {
+ get
+ {
+ var gpo = GpoMaxForegroundKBps.HasValue || GpoMaxBackgroundKBps.HasValue ||
+ GpoPctMaxForeground.HasValue || GpoPctMaxBackground.HasValue;
+ var mdm = MdmMaxForegroundKBps.HasValue || MdmMaxBackgroundKBps.HasValue ||
+ MdmPctMaxForeground.HasValue || MdmPctMaxBackground.HasValue;
+ if (gpo && mdm) return "gpo+mdm";
+ if (gpo) return "gpo";
+ if (mdm) return "mdm";
+ return null;
+ }
+ }
+ }
+
+ ///
+ /// Reads the DO throttle policies fresh at each emission (Intune policies land DURING the
+ /// enrollment — a constructor-time read would miss them). Fail-soft: any registry error
+ /// yields an empty snapshot, never throws.
+ ///
+ public static class DoThrottlePolicyReader
+ {
+ public const string GpoKeyPath = @"SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization";
+ public const string MdmKeyPath = @"SOFTWARE\Microsoft\PolicyManager\current\device\DeliveryOptimization";
+
+ private const string MaxForegroundValue = "DOMaxForegroundDownloadBandwidth";
+ private const string MaxBackgroundValue = "DOMaxBackgroundDownloadBandwidth";
+ private const string PctMaxForegroundValue = "DOPercentageMaxForegroundBandwidth";
+ private const string PctMaxBackgroundValue = "DOPercentageMaxBackgroundBandwidth";
+ private const string DownloadModeValue = "DODownloadMode";
+
+ /// Reads both policy stores from the 64-bit registry view.
+ public static DoThrottlePolicySnapshot Read()
+ {
+ return ReadCore(RegistryValueGetter);
+ }
+
+ ///
+ /// Testable core: maps (keyPath, valueName) to the raw
+ /// registry value (or null when absent).
+ ///
+ internal static DoThrottlePolicySnapshot ReadCore(Func getValue)
+ {
+ var snapshot = new DoThrottlePolicySnapshot();
+ try
+ {
+ snapshot.GpoMaxForegroundKBps = ReadCap(getValue, GpoKeyPath, MaxForegroundValue);
+ snapshot.GpoMaxBackgroundKBps = ReadCap(getValue, GpoKeyPath, MaxBackgroundValue);
+ snapshot.GpoPctMaxForeground = ReadCap(getValue, GpoKeyPath, PctMaxForegroundValue);
+ snapshot.GpoPctMaxBackground = ReadCap(getValue, GpoKeyPath, PctMaxBackgroundValue);
+ snapshot.GpoDownloadMode = ReadInt(getValue, GpoKeyPath, DownloadModeValue);
+
+ snapshot.MdmMaxForegroundKBps = ReadCap(getValue, MdmKeyPath, MaxForegroundValue);
+ snapshot.MdmMaxBackgroundKBps = ReadCap(getValue, MdmKeyPath, MaxBackgroundValue);
+ snapshot.MdmPctMaxForeground = ReadCap(getValue, MdmKeyPath, PctMaxForegroundValue);
+ snapshot.MdmPctMaxBackground = ReadCap(getValue, MdmKeyPath, PctMaxBackgroundValue);
+ snapshot.MdmDownloadMode = ReadInt(getValue, MdmKeyPath, DownloadModeValue);
+ }
+ catch
+ {
+ // Fail-soft: a partially filled snapshot is fine; context fields are best-effort.
+ }
+ return snapshot;
+ }
+
+ /// Cap semantics: 0 = "dynamic / no limit" (OS default) → treated as absent.
+ private static int? ReadCap(Func getValue, string keyPath, string valueName)
+ {
+ var value = ReadInt(getValue, keyPath, valueName);
+ return value.HasValue && value.Value > 0 ? value : null;
+ }
+
+ private static int? ReadInt(Func getValue, string keyPath, string valueName)
+ {
+ try
+ {
+ var raw = getValue(keyPath, valueName);
+ if (raw == null) return null;
+ // DWORDs arrive as int; PolicyManager occasionally stores numbers as strings.
+ return Convert.ToInt32(raw);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static object? RegistryValueGetter(string keyPath, string valueName)
+ {
+ // Registry64 explicitly — SOFTWARE\Policies is WOW64-redirected, and the policy
+ // stores live in the 64-bit view regardless of the agent's bitness.
+ using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
+ using (var key = hklm.OpenSubKey(keyPath))
+ {
+ return key?.GetValue(valueName);
+ }
+ }
+ }
+}