feat(backend,mcp): typed API contract COMPLETED — every remaining top-level success body is a manifest-exported wire DTO, and the guard now catches the bypass shapes that let them escape. The 08-31 migration's ratchet only saw inline WriteAsJsonAsync(new {...}); four bypass classes had kept ~20 endpoint families untyped: (A) Shared classes without IApiResponse — GeographicMetricsResponse, FleetHealthMetrics, ImePatternHealthResponse, SlaMetricsResponse, PlatformUsageMetrics now implement it (wire byte-identical, interface is empty); (B) Functions-local named response families moved verbatim to Shared — PlatformAgentMetricsResponse/SessionAgentMetric/DeliveryLatencyMetrics/CrashRateMetrics/CrashExceptionSummary, AgentEfficiencyMetricsResponse family (CloneAsCacheHit relocated into the service; init→set for netstandard2.0), OffboardResponse; (C) anonymous/object builders converted to DTOs with old-vs-new wire-parity facts (TypedContractCompletionWireParityTests, 14 facts incl. every vanishing-key case): rule-stats x2 (RuleStatsResponse; uniqueRules is global-only via an optional slot at its exact wire position), verdict-calibration (builder renamed VerdictCalibrationResponseBuilder, PathRow/TrendWindow to Shared), metrics/app (MetricsMath), apps list/analytics/sessions x6 routes (AppsAnalyticsHelper; legacy + paged list share ONE DTO via optional paging keys), ime-versions lean projection (ImeVersionHistoryLeanEntry [WireContract]; bare-array envelope stays, typed as IReadOnlyList<object>), device-journeys + time-attribution fleet envelopes, feature-flags, diagnostics/paths, session restore, cascade-delete Enqueued arm (error arms stay anonymous by design), feedback eligibility/list; (D) Serialize+WriteString path — ListBackupsResponse under the backup surface's own serializer options; AppInsightsQuery's Serialize is an outgoing REQUEST body (not response debt, baselined as such). TypedResponseGuardTests gains three ratchets with mutation-proven red/green: WriteAsJsonAsync(<variable assigned an anonymous object>) (empty baseline), JsonSerializer.Serialize(new{ (error-tolerant; 1 documented request-body entry), and object/Task<object>-returning Build*/Compute*/*Payload/*Response methods (1 documented union entry); the local-WriteJson-wrapper class is closed structurally (generic T : IApiResponse — an interface-typed parameter would serialize zero properties, the generic keeps the concrete type). Manifest regenerated 208→298 exported types; MCP adopts the new types (get_rule_stats typed with the trend-drop as explicit output shaping, get_platform_metrics drops its hand-declared mirror for PlatformAgentMetricsResponse, verdict-calibration-shape derives its rows via extends Omit<generated wire row> so backend renames go tsc-red). Six backend shape-test files switched from default-options serialization to the production wire options via TestWire (ApiJsonOptions; the old default-options runs only matched while payloads were anonymous with lowercase members), and three assertions that pinned null-VALUED keys now pin key ABSENCE — production always omitted them (WhenWritingNull); the parity facts prove the wire itself is unchanged. Deliberately still untyped: auth/me, CheckMcpAccess dictionary, TenantAdmins/GlobalAdmins entity-row arrays and the object-slot payloads (RuleDryRun, HealthCheck, TenantConfigFieldSchema, ...) — the documented read-model follow-up. Suites: Backend 5323, DecisionCore 577, Agent 2805, SummaryDialog 44, MCP 617, Web tsc 0 + vitest 957, generator idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-31 04:32:51 +02:00
parent c04280cf68
commit 51737174b2
53 changed files with 10263 additions and 700 deletions
@@ -265,7 +265,7 @@ public class AppInstallIdentityAndEspBlockingTests
Summary("Company Portal", status: "Failed", collision: true),
};
var root = JsonSerializer.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
var root = TestWire.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
Assert.Equal(1, root.GetProperty("totalApps").GetInt32()); // collided name forms no group
Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged
@@ -282,7 +282,7 @@ public class AppInstallIdentityAndEspBlockingTests
Summary("Company Portal", collision: true),
};
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var apps = root.GetProperty("apps").EnumerateArray().ToList();
@@ -305,7 +305,7 @@ public class AppInstallIdentityAndEspBlockingTests
Summary("Company Portal", status: "Failed", collision: true), // the second identity's failure
};
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, repo.Object, "Company Portal", days: 30));
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
@@ -324,7 +324,7 @@ public class AppInstallIdentityAndEspBlockingTests
var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) };
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, repo.Object, "Company Portal", days: 30));
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
@@ -363,7 +363,7 @@ public class AppVersionRegressionRadarTests
Install("1.0", 50, status: "Failed"), // failed — excluded from durations
};
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
rows, sessionRepo.Object, App, days: 30));
var version = root.GetProperty("versionBreakdown")[0];
@@ -383,19 +383,18 @@ public class AppVersionRegressionRadarTests
.ReturnsAsync((SessionSummary?)null);
var alert = Alert();
// The Functions host serializes camelCase on the wire; mirror that here so the
// TestWire serializes with the REAL wire options (ApiJsonOptions — camelCase), so the
// asserted property names match what the web client actually parses.
var wireOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
// Empty-data early return still surfaces the block (episodes can outlive window data).
var emptyRoot = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
var emptyRoot = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
new List<AppVersionRegressionAlert> { alert }), wireOptions);
new List<AppVersionRegressionAlert> { alert }));
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
new List<AppInstallSummary> { Install("1.0", 100) }, sessionRepo.Object, App, days: 30,
new List<AppVersionRegressionAlert> { alert }), wireOptions);
new List<AppVersionRegressionAlert> { alert }));
var regression = root.GetProperty("versionRegressions")[0];
Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString());
@@ -53,7 +53,7 @@ public class AppsAnalyticsFailureRateTests
A("InProgress"),
};
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
@@ -67,7 +67,7 @@ public class AppsAnalyticsFailureRateTests
{
var summaries = new List<AppInstallSummary> { A("InProgress"), A("InProgress") };
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
Assert.Equal(0d, root.GetProperty("apps")[0].GetProperty("failureRate").GetDouble());
@@ -90,7 +90,7 @@ public class AppsAnalyticsFailureRateTests
for (var i = 0; i < 2; i++) summaries.Add(A("Succeeded", startedAt: secondHalf));
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: secondHalf));
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
@@ -109,12 +109,13 @@ public class AppsAnalyticsFailureRateTests
for (var i = 0; i < 4; i++) summaries.Add(A("Failed", startedAt: now.AddDays(-5)));
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: now.AddDays(-5)));
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
Assert.Equal("stable", app.GetProperty("trend").GetString());
Assert.Equal(JsonValueKind.Null, app.GetProperty("trendDelta").ValueKind);
// Wire options omit null keys (WhenWritingNull): a withheld delta has no key at all.
Assert.False(app.TryGetProperty("trendDelta", out _));
}
// ── /apps/{appName}/analytics ───────────────────────────────────────────
@@ -131,7 +132,7 @@ public class AppsAnalyticsFailureRateTests
}
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, sessionRepo, "Contoso App", days: 30);
return JsonSerializer.SerializeToElement(result);
return TestWire.SerializeToElement(result);
}
[Fact]
@@ -176,7 +177,7 @@ public class AppsAnalyticsFailureRateTests
var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured };
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
@@ -24,7 +24,7 @@ public class AppsAnalyticsHelperPagingTests
}
private static JsonElement Build(int? pageSize, int skip = 0) =>
JsonSerializer.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
TestWire.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
FiveApps(), days: 30, pageSize: pageSize, skip: skip,
nextLinkForOffset: o => $"/api/apps/list?pageSize={pageSize}&skip={o}"));
@@ -55,12 +55,13 @@ public class AppsAnalyticsHelperPagingTests
}
[Fact]
public void PageSize_LastPage_HasNullNextLink()
public void PageSize_LastPage_OmitsNextLink()
{
var root = Build(pageSize: 2, skip: 4);
Assert.Equal(1, root.GetProperty("count").GetInt32());
Assert.Equal("Echo", root.GetProperty("apps")[0].GetProperty("appName").GetString());
Assert.Equal(JsonValueKind.Null, root.GetProperty("nextLink").ValueKind);
// Wire options omit null keys (WhenWritingNull): no further page → no nextLink key.
Assert.False(root.TryGetProperty("nextLink", out _));
}
}
@@ -159,16 +159,18 @@ public class DeleteSessionFunctionTests
// ── Anonymous-object reflection helpers ───────────────────────────────────────────────
// IgnoreCase: the Enqueued arm is a typed DTO (PascalCase properties, camelCased on the
// wire by ApiJsonOptions); the error arms stay anonymous (camelCase). Both resolve here.
private static void AssertProperty(object body, string propertyName, object? expected)
{
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
Assert.NotNull(prop);
Assert.Equal(expected, prop!.GetValue(body));
}
private static T GetProperty<T>(object body, string propertyName)
{
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
Assert.NotNull(prop);
var value = prop!.GetValue(body);
return (T)value!;
@@ -702,7 +702,7 @@ public class DeviceJourneyAndFtrTests
},
};
var totals = DeviceJourneyMetricsResponse.SumAggregates(daily);
var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(daily);
Assert.Equal(3, totals.CompletedJourneys);
Assert.Equal(2, totals.FirstTimeRight);
@@ -716,7 +716,7 @@ public class DeviceJourneyAndFtrTests
[Fact]
public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero()
{
var totals = DeviceJourneyMetricsResponse.SumAggregates(new List<DeviceJourneyDailyAggregate>
var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(new List<DeviceJourneyDailyAggregate>
{
new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 },
});
@@ -734,7 +734,7 @@ public class DeviceJourneyAndFtrTests
[InlineData("999", 180)] // clamped to aggregate retention
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
{
Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw));
Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
}
[Fact]
@@ -743,8 +743,8 @@ public class DeviceJourneyAndFtrTests
// Codex review: both range ends are inclusive, so "today - days" returned N+1 keys —
// days=1 summed yesterday AND today.
var today = new DateTime(2026, 7, 27);
Assert.Equal(today, DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 1));
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 30));
Assert.Equal(today, DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 1));
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 30));
}
private static DeviceHistory RepeaterHistory(
@@ -780,7 +780,7 @@ public class DeviceJourneyAndFtrTests
Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))),
};
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
Assert.Equal(2, selected.Count);
Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc
@@ -799,7 +799,7 @@ public class DeviceJourneyAndFtrTests
Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i))));
}
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
Assert.Equal(10, selected.Count);
Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first
@@ -19,7 +19,7 @@ public class GetTenantFeatureFlagsPayloadTests
private static JsonElement Serialize(TenantConfiguration config)
{
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
var json = JsonSerializer.Serialize(payload);
var json = TestWire.Serialize(payload);
return JsonDocument.Parse(json).RootElement;
}
@@ -49,7 +49,9 @@ public class GetTenantFeatureFlagsPayloadTests
"isTrial",
"showScriptOutput",
"trialAvailable",
"trialExpiresUtc",
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
// wire options omit null keys (WhenWritingNull) — exactly what production always
// served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial.
"unrestrictedMode",
"validateAutopilotDevice",
}, fieldNames);
@@ -162,7 +164,7 @@ public class GetTenantFeatureFlagsPayloadTests
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
};
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
Assert.DoesNotContain("secret-sas-token", json);
Assert.DoesNotContain("secret-team-hook", json);
@@ -270,7 +272,7 @@ public class GetTenantFeatureFlagsPayloadTests
[Fact]
public void Payload_ContactEmailSet_NeverLeaksTheAddress()
{
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
Assert.DoesNotContain("secret-contact", json);
@@ -18,7 +18,7 @@ public class MetricsMathAppPayloadTests
private static JsonElement Build(IEnumerable<AppInstallSummary> summaries)
{
var payload = MetricsMath.BuildAppMetricsPayload(summaries);
return JsonSerializer.SerializeToElement(payload);
return TestWire.SerializeToElement(payload);
}
[Fact]
@@ -0,0 +1,21 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Serializes test payloads with the PRODUCTION wire options (<see cref="ApiJsonOptions"/> —
/// camelCase, WhenWritingNull), the policy every test harness with its own serializer must
/// follow. Shape tests serialized DTOs with default options before the 2026-08-31 completion
/// pass; that only worked while the payloads were anonymous objects with lowercase members.
/// Generic so the CONCRETE type reaches System.Text.Json (a declared object parameter would
/// too, but the generic also keeps compile-time typing at the call site).
/// </summary>
internal static class TestWire
{
private static readonly JsonSerializerOptions Options = ApiJsonOptions.Create();
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Options);
public static JsonElement SerializeToElement<T>(T value) => JsonSerializer.SerializeToElement(value, Options);
}
@@ -465,6 +465,6 @@ public class TimeAttributionPersistenceAndAggregationTests
// returned 31 day keys under a "windowDays: 30" label.
var today = new System.DateTime(2026, 7, 27);
Assert.Equal(today.AddDays(-29),
Functions.Metrics.TimeAttributionResponse.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponse.WindowDays));
Functions.Metrics.TimeAttributionResponseBuilder.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponseBuilder.WindowDays));
}
}
@@ -0,0 +1,879 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Backup;
using AutopilotMonitor.Shared.Models.Metrics;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proofs for the 2026-08-31 typed-contract COMPLETION pass: the endpoints the
/// original migration missed because their anonymous literal was smuggled past the ratchet
/// (variable indirection, object-returning builders, Serialize+WriteString, local WriteJson
/// wrappers). Same contract as <see cref="ApiResponseWireParityTests"/>: OLD anonymous
/// literal (copied from the pre-conversion diff) vs NEW DTO, ordinal JSON equality, plus a
/// null-slot case wherever a key must vanish. Sites converted by MOVING an already-named
/// class (PlatformAgentMetricsResponse family, AgentEfficiency family, VerdictCalibration
/// path rows, DeviceJourneyWindowTotals, OffboardResponse) are wire-identical by
/// construction (System.Text.Json ignores namespaces) and carry no fact.
/// </summary>
public class TypedContractCompletionWireParityTests
{
private static void AssertWireIdentical(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
// ── rule-stats (tenant + global) ─────────────────────────────────────────────────────
[Fact]
public void RuleStatsResponse_matches_the_tenant_shape_without_uniqueRules()
{
var regressions = new List<RuleRegressionAlert>();
AssertWireIdentical(
new
{
rules = new[]
{
new
{
ruleId = "ANALYZE-NET-001",
ruleType = "analyze",
ruleTitle = "Network issue",
category = "network",
severity = "warning",
fireCount = 3,
evaluationCount = 10,
sessionsEvaluated = 9,
hitRate = 30.0,
avgConfidenceScore = 71.5,
trend = new[] { new { date = "2026-08-30", fireCount = 3, evaluationCount = 10 } },
},
},
regressions,
summary = new
{
totalEvaluations = 10,
totalFires = 3,
overallHitRate = 30.0,
topRuleByFireCount = (string?)"ANALYZE-NET-001",
period = new { start = "2026-08-01", end = "2026-08-31" },
},
},
new RuleStatsResponse
{
Rules = new[]
{
new RuleStatsRuleAggregate
{
RuleId = "ANALYZE-NET-001",
RuleType = "analyze",
RuleTitle = "Network issue",
Category = "network",
Severity = "warning",
FireCount = 3,
EvaluationCount = 10,
SessionsEvaluated = 9,
HitRate = 30.0,
AvgConfidenceScore = 71.5,
Trend = new[] { new RuleTrendPoint { Date = "2026-08-30", FireCount = 3, EvaluationCount = 10 } },
},
},
Regressions = regressions,
// Tenant route: UniqueRules stays null → the key is absent, matching the
// historical tenant shape which never carried it.
Summary = new RuleStatsSummary
{
TotalEvaluations = 10,
TotalFires = 3,
OverallHitRate = 30.0,
TopRuleByFireCount = "ANALYZE-NET-001",
UniqueRules = null,
Period = new RuleStatsPeriod { Start = "2026-08-01", End = "2026-08-31" },
},
});
}
[Fact]
public void RuleStatsResponse_matches_the_global_shape_with_uniqueRules_and_null_topRule()
{
var regressions = new List<RuleRegressionAlert>();
AssertWireIdentical(
new
{
rules = Array.Empty<object>(),
regressions,
summary = new
{
totalEvaluations = 0,
totalFires = 0,
overallHitRate = 0.0,
topRuleByFireCount = (string?)null, // empty window → key vanishes
uniqueRules = 0,
period = new { start = "2026-08-01", end = "2026-08-31" },
},
},
new RuleStatsResponse
{
Rules = Array.Empty<RuleStatsRuleAggregate>(),
Regressions = regressions,
Summary = new RuleStatsSummary
{
TotalEvaluations = 0,
TotalFires = 0,
OverallHitRate = 0.0,
TopRuleByFireCount = null,
UniqueRules = 0,
Period = new RuleStatsPeriod { Start = "2026-08-01", End = "2026-08-31" },
},
});
}
// ── verdict-calibration ──────────────────────────────────────────────────────────────
[Fact]
public void VerdictCalibrationResponse_matches_the_envelope_incl_computedAt_omission()
{
// Path rows and alerts were ALREADY named classes on the old wire (moved verbatim) —
// the envelope keys are what the conversion changed, so the fact pins those with the
// row lists empty; the no-data window additionally proves computedAt vanishes.
var paths = new List<VerdictCalibrationPathRow>();
var alerts = new List<VerdictCalibrationAlert>();
AssertWireIdentical(
new
{
success = true,
tenantId = "global",
windowDays = 30,
windowStart = "2026-08-02",
windowEnd = "2026-08-31",
computedAt = (DateTime?)null,
versions = new[] { 1, 2 },
totals = new { sessions = 0, terminal = 0, derived = 0, days = 0 },
trend = new { windowDays = 7, baselineDays = 28, windowSessions = 0, baselineSessions = 0 },
paths,
alerts,
},
new VerdictCalibrationResponse
{
Success = true,
TenantId = "global",
WindowDays = 30,
WindowStart = "2026-08-02",
WindowEnd = "2026-08-31",
ComputedAt = null,
Versions = new[] { 1, 2 },
Totals = new VerdictCalibrationTotals { Sessions = 0, Terminal = 0, Derived = 0, Days = 0 },
Trend = new VerdictCalibrationTrendMeta { WindowDays = 7, BaselineDays = 28, WindowSessions = 0, BaselineSessions = 0 },
Paths = paths,
Alerts = alerts,
});
}
// ── metrics/app ──────────────────────────────────────────────────────────────────────
[Fact]
public void AppMetricsResponse_matches_the_anonymous_shape()
{
AssertWireIdentical(
new
{
success = true,
totalApps = 1,
totalInstalls = 4,
totalSkipped = 1,
totalUnmeasured = 1,
totalCollisionExcluded = 0,
slowestApps = new[]
{
new
{
appName = "Contoso App",
totalInstalls = 4,
succeeded = 2,
skipped = 1,
unmeasured = 1,
failed = 1,
failureRate = 33.3,
avgDurationSeconds = 120.0,
maxDurationSeconds = 240,
measuredInstalls = 1,
avgDownloadBytes = 1048576L,
doTotalBytesDownloaded = 2097152L,
doBytesFromPeers = 1048576L,
doBytesFromCacheServer = 0L,
doBytesFromHttp = 1048576L,
peerOffloadPercent = 50.0,
topFailureCodes = new[] { new { code = "0x80070005", count = 1 } },
},
},
topFailingApps = Array.Empty<object>(),
deliveryOptimization = new
{
totalBytesDownloaded = 2097152L,
fromPeers = 1048576L,
fromCacheServer = 0L,
fromHttp = 1048576L,
peerOffloadPercent = 50.0,
},
},
new AppMetricsResponse
{
Success = true,
TotalApps = 1,
TotalInstalls = 4,
TotalSkipped = 1,
TotalUnmeasured = 1,
TotalCollisionExcluded = 0,
SlowestApps = new[]
{
new AppMetricsAppGroup
{
AppName = "Contoso App",
TotalInstalls = 4,
Succeeded = 2,
Skipped = 1,
Unmeasured = 1,
Failed = 1,
FailureRate = 33.3,
AvgDurationSeconds = 120.0,
MaxDurationSeconds = 240,
MeasuredInstalls = 1,
AvgDownloadBytes = 1048576L,
DoTotalBytesDownloaded = 2097152L,
DoBytesFromPeers = 1048576L,
DoBytesFromCacheServer = 0L,
DoBytesFromHttp = 1048576L,
PeerOffloadPercent = 50.0,
TopFailureCodes = new[] { new AppFailureCodeCount { Code = "0x80070005", Count = 1 } },
},
},
TopFailingApps = Array.Empty<AppMetricsAppGroup>(),
DeliveryOptimization = new AppMetricsDeliveryOptimization
{
TotalBytesDownloaded = 2097152L,
FromPeers = 1048576L,
FromCacheServer = 0L,
FromHttp = 1048576L,
PeerOffloadPercent = 50.0,
},
});
}
// ── metrics/ime-versions (non-global projection) ─────────────────────────────────────
[Fact]
public void ImeVersionHistoryLeanEntry_matches_the_anonymous_projection()
{
var seen = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
var expected = JsonSerializer.Serialize(
new { Version = "1.104.102.0", FirstSeenAt = seen, LastSeenAt = seen.AddDays(3), SessionCount = 12 },
ApiJsonOptions.Create());
var actual = JsonSerializer.Serialize(
new ImeVersionHistoryLeanEntry
{
Version = "1.104.102.0",
FirstSeenAt = seen,
LastSeenAt = seen.AddDays(3),
SessionCount = 12,
},
ApiJsonOptions.Create());
Assert.Equal(expected, actual);
}
// ── metrics/time-attribution (fleet) ─────────────────────────────────────────────────
[Fact]
public void TimeAttributionMetricsResponse_matches_the_envelope()
{
// Row lists are TimeAttributionDailyAggregate on BOTH sides (identical class) —
// the envelope keys are the conversion.
var rows = new List<TimeAttributionDailyAggregate>();
AssertWireIdentical(
new { success = true, windowDays = 30, classes = rows, daily = rows },
new TimeAttributionMetricsResponse { Success = true, WindowDays = 30, Classes = rows, Daily = rows });
}
// ── metrics/device-journeys ──────────────────────────────────────────────────────────
[Fact]
public void DeviceJourneyMetricsResponse_matches_the_envelope_incl_repeatDevices_omission()
{
var totals = new DeviceJourneyWindowTotals { CompletedJourneys = 0, FirstTimeRight = 0, FtrRatePct = null, ExcludedSessions = 0 };
var daily = new List<DeviceJourneyDailyAggregate>();
// Global aggregate: repeatDevices=null → the key vanishes (the disclosed gap).
AssertWireIdentical(
new { success = true, windowDays = 30, totals, daily, repeatDevices = (List<object>?)null },
new DeviceJourneyMetricsResponse { Success = true, WindowDays = 30, Totals = totals, Daily = daily, RepeatDevices = null });
}
[Fact]
public void DeviceJourneyRepeatDevice_matches_the_anonymous_row()
{
var started = new DateTime(2026, 8, 29, 8, 0, 0, DateTimeKind.Utc);
AssertWireIdentical(
new
{
success = true,
windowDays = 30,
totals = new DeviceJourneyWindowTotals(),
daily = new List<DeviceJourneyDailyAggregate>(),
repeatDevices = new[]
{
new
{
serialNumber = "SER123",
manufacturer = "Contoso",
model = "Book 3",
attempts = 3,
journeyCount = 2,
lastStatus = "Failed",
lastSessionId = "22222222-2222-2222-2222-222222222222",
lastStartedAt = started,
lastFailureReason = "ESP timeout",
},
},
},
new DeviceJourneyMetricsResponse
{
Success = true,
WindowDays = 30,
Totals = new DeviceJourneyWindowTotals(),
Daily = new List<DeviceJourneyDailyAggregate>(),
RepeatDevices = new[]
{
new DeviceJourneyRepeatDevice
{
SerialNumber = "SER123",
Manufacturer = "Contoso",
Model = "Book 3",
Attempts = 3,
JourneyCount = 2,
LastStatus = "Failed",
LastSessionId = "22222222-2222-2222-2222-222222222222",
LastStartedAt = started,
LastFailureReason = "ESP timeout",
},
},
});
}
// ── config/{tenantId}/feature-flags ──────────────────────────────────────────────────
[Fact]
public void TenantFeatureFlagsResponse_matches_the_anonymous_shape_incl_null_omissions()
{
AssertWireIdentical(
new
{
bootstrapTokenEnabled = true,
diagnosticsUploadConfigured = false,
validateAutopilotDevice = true,
appHomingFunnelActive = false,
showScriptOutput = true,
enableSoftwareInventoryAnalyzer = false,
enableIntegrityBypassAnalyzer = true,
unrestrictedMode = false,
edition = "community",
isTrial = false,
trialExpiresUtc = (DateTime?)null, // not on trial → key vanishes
trialAvailable = true,
contactEmailSet = false,
entitlements = new
{
retentionCapDays = 90,
userRateLimitPerMinute = (int?)null, // platform default → key vanishes
delegatedAdminAllowed = false,
mcpUsagePlan = "community",
},
},
new TenantFeatureFlagsResponse
{
BootstrapTokenEnabled = true,
DiagnosticsUploadConfigured = false,
ValidateAutopilotDevice = true,
AppHomingFunnelActive = false,
ShowScriptOutput = true,
EnableSoftwareInventoryAnalyzer = false,
EnableIntegrityBypassAnalyzer = true,
UnrestrictedMode = false,
Edition = "community",
IsTrial = false,
TrialExpiresUtc = null,
TrialAvailable = true,
ContactEmailSet = false,
Entitlements = new TenantFeatureEntitlements
{
RetentionCapDays = 90,
UserRateLimitPerMinute = null,
DelegatedAdminAllowed = false,
McpUsagePlan = "community",
},
});
}
// ── diagnostics/paths ────────────────────────────────────────────────────────────────
[Fact]
public void DiagnosticsPathsResponse_matches_the_anonymous_shape()
{
var globalPaths = new List<DiagnosticsLogPath>();
AssertWireIdentical(
new
{
builtIn = new[]
{
new
{
id = "AgentLogs",
zipFolder = "AgentLogs",
sourceFolder = "%ProgramData%/AutopilotMonitor",
patterns = new[] { "*.log" },
includeSubfolders = false,
description = "Agent log files",
condition = "Always",
},
},
globalPaths,
},
new DiagnosticsPathsResponse
{
BuiltIn = new[]
{
new DiagnosticsBuiltInSectionWire
{
Id = "AgentLogs",
ZipFolder = "AgentLogs",
SourceFolder = "%ProgramData%/AutopilotMonitor",
Patterns = new[] { "*.log" },
IncludeSubfolders = false,
Description = "Agent log files",
Condition = "Always",
},
},
GlobalPaths = globalPaths,
});
}
// ── sessions restore / delete-queued ─────────────────────────────────────────────────
[Fact]
public void SessionRestoreResponse_matches_the_anonymous_shape_incl_null_omissions()
{
var rows = new Dictionary<string, int>(StringComparer.Ordinal) { ["Sessions"] = 1 };
var empty = new Dictionary<string, int>(StringComparer.Ordinal);
AssertWireIdentical(
new
{
success = true,
outcome = "Restored",
mode = (string?)"full",
message = (string?)null, // clean success → key vanishes
currentState = (string?)null,
pendingManifestId = (string?)null,
rowsRestoredByTable = rows,
rowsSkippedByTable = empty,
wouldRestoreByTable = empty,
inventoryReIncrements = 2,
durationMs = 1234L,
},
new SessionRestoreResponse
{
Success = true,
Outcome = "Restored",
Mode = "full",
Message = null,
CurrentState = null,
PendingManifestId = null,
RowsRestoredByTable = rows,
RowsSkippedByTable = empty,
WouldRestoreByTable = empty,
InventoryReIncrements = 2,
DurationMs = 1234L,
});
}
[Fact]
public void SessionDeletionQueuedResponse_matches_the_anonymous_Enqueued_arm()
{
AssertWireIdentical(
new
{
success = true,
status = "queued",
manifestId = (string?)"01J0123456789ABCDEFGHIJKLM",
message = "Cascade deletion queued; worker will drain asynchronously.",
},
new SessionDeletionQueuedResponse
{
Success = true,
Status = "queued",
ManifestId = "01J0123456789ABCDEFGHIJKLM",
Message = "Cascade deletion queued; worker will drain asynchronously.",
});
}
// ── feedback ─────────────────────────────────────────────────────────────────────────
[Fact]
public void Feedback_responses_match_the_anonymous_shapes()
{
AssertWireIdentical(new { eligible = true }, new FeedbackEligibilityResponse { Eligible = true });
AssertWireIdentical(
new
{
feedback = new[]
{
new
{
type = "InApp",
upn = "alice@contoso.com",
tenantId = "11111111-1111-1111-1111-111111111111",
displayName = "Alice",
rating = (int?)5,
comment = (string?)"Great",
dismissed = false,
submitted = true,
interactedAt = (string?)"2026-08-30T10:00:00.0000000Z",
historyRowKey = (string?)null, // in-app entry → key vanishes
domainName = (string?)null,
},
},
},
new FeedbackListResponse
{
Feedback = new[]
{
new FeedbackEntryWire
{
Type = "InApp",
Upn = "alice@contoso.com",
TenantId = "11111111-1111-1111-1111-111111111111",
DisplayName = "Alice",
Rating = 5,
Comment = "Great",
Dismissed = false,
Submitted = true,
InteractedAt = "2026-08-30T10:00:00.0000000Z",
HistoryRowKey = null,
DomainName = null,
},
},
});
}
// ── global/backups (custom serializer options) ───────────────────────────────────────
[Fact]
public void ListBackupsResponse_matches_the_anonymous_shape_under_backup_options()
{
var ids = new List<string> { "20260831T010203Z_abcd1234" };
var expected = JsonSerializer.Serialize(new { backupIds = ids }, BackupManifestJson.SerializerOptions);
var actual = JsonSerializer.Serialize(new ListBackupsResponse { BackupIds = ids }, BackupManifestJson.SerializerOptions);
Assert.Equal(expected, actual);
}
// ── apps list / analytics / sessions ─────────────────────────────────────────────────
[Fact]
public void AppsListResponse_matches_the_legacy_and_paged_shapes()
{
var item = new AppsListItem
{
AppName = "Contoso App",
AppType = "Win32",
TotalInstalls = 5,
Succeeded = 3,
Skipped = 1,
Unmeasured = 0,
Failed = 1,
FailureRate = 25.0,
AvgDurationSeconds = 60.0,
MaxDurationSeconds = 120,
AvgDownloadBytes = 1024L,
Trend = "stable",
TrendDelta = null, // under 5 finished installs in a half → key vanishes
LastSeenAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc),
};
var anonymousItem = new
{
appName = "Contoso App",
appType = "Win32",
totalInstalls = 5,
succeeded = 3,
skipped = 1,
unmeasured = 0,
failed = 1,
failureRate = 25.0,
avgDurationSeconds = 60.0,
maxDurationSeconds = 120,
avgDownloadBytes = 1024L,
trend = "stable",
trendDelta = (double?)null,
lastSeenAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc),
};
// Legacy mode: no paging keys at all.
AssertWireIdentical(
new { success = true, totalApps = 1, totalInstalls = 5, collisionExcluded = 0, windowDays = 30, apps = new[] { anonymousItem } },
new AppsListResponse
{
Success = true,
TotalApps = 1,
TotalInstalls = 5,
CollisionExcluded = 0,
WindowDays = 30,
Apps = new[] { item },
});
// Paged mode incl. last-page nextLink omission.
AssertWireIdentical(
new
{
success = true,
totalApps = 1,
totalInstalls = 5,
collisionExcluded = 0,
windowDays = 30,
count = 1,
offset = 0,
pageSize = 100,
apps = new[] { anonymousItem },
nextLink = (string?)null,
},
new AppsListResponse
{
Success = true,
TotalApps = 1,
TotalInstalls = 5,
CollisionExcluded = 0,
WindowDays = 30,
Count = 1,
Offset = 0,
PageSize = 100,
Apps = new[] { item },
NextLink = null,
});
}
[Fact]
public void AppAnalyticsResponse_matches_the_empty_window_shape()
{
var regressions = new List<AppVersionRegressionAlert>();
AssertWireIdentical(
new
{
success = true,
appName = "Contoso App",
appType = string.Empty,
windowDays = 30,
collisionExcluded = 0,
bucket = "day",
summary = new
{
totalInstalls = 0,
succeeded = 0,
skipped = 0,
unmeasured = 0,
failed = 0,
failureRate = 0.0,
avgDurationSeconds = 0.0,
p95DurationSeconds = 0,
avgDownloadBytes = 0L,
trend = "stable",
trendDelta = (double?)null,
flakinessScore = 0.0,
},
timeSeries = Array.Empty<object>(),
versionBreakdown = Array.Empty<object>(),
installerPhaseBreakdown = Array.Empty<object>(),
topFailureCodes = Array.Empty<object>(),
detectionLiesCount = 0,
deviceModelBreakdown = Array.Empty<object>(),
versionRegressions = regressions,
},
new AppAnalyticsResponse
{
Success = true,
AppName = "Contoso App",
AppType = string.Empty,
WindowDays = 30,
CollisionExcluded = 0,
Bucket = "day",
Summary = new AppAnalyticsSummary
{
TotalInstalls = 0,
Succeeded = 0,
Skipped = 0,
Unmeasured = 0,
Failed = 0,
FailureRate = 0.0,
AvgDurationSeconds = 0.0,
P95DurationSeconds = 0,
AvgDownloadBytes = 0L,
Trend = "stable",
TrendDelta = null,
FlakinessScore = 0.0,
},
TimeSeries = Array.Empty<AppAnalyticsTimeBucket>(),
VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
DetectionLiesCount = 0,
DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
VersionRegressions = regressions,
});
}
[Fact]
public void AppAnalytics_inner_rows_match_their_anonymous_shapes()
{
var bucketStart = new DateTime(2026, 8, 29, 0, 0, 0, DateTimeKind.Utc);
var wire = ApiJsonOptions.Create();
Assert.Equal(
JsonSerializer.Serialize(new
{
bucketStart,
installs = 4,
succeeded = 3,
failed = 1,
failureRate = 25.0,
avgDurationSeconds = 90.0,
}, wire),
JsonSerializer.Serialize(new AppAnalyticsTimeBucket
{
BucketStart = bucketStart,
Installs = 4,
Succeeded = 3,
Failed = 1,
FailureRate = 25.0,
AvgDurationSeconds = 90.0,
}, wire));
Assert.Equal(
JsonSerializer.Serialize(new
{
appVersion = "1.2.3",
installs = 4,
failed = 1,
failureRate = 25.0,
measuredInstalls = 3,
medianDurationSeconds = 60,
p95DurationSeconds = 110,
}, wire),
JsonSerializer.Serialize(new AppVersionBreakdownItem
{
AppVersion = "1.2.3",
Installs = 4,
Failed = 1,
FailureRate = 25.0,
MeasuredInstalls = 3,
MedianDurationSeconds = 60,
P95DurationSeconds = 110,
}, wire));
Assert.Equal(
JsonSerializer.Serialize(new { phase = "DownloadStart", failed = 2 }, wire),
JsonSerializer.Serialize(new AppInstallerPhaseCount { Phase = "DownloadStart", Failed = 2 }, wire));
Assert.Equal(
JsonSerializer.Serialize(new
{
code = "0x80070005",
exitCode = (int?)5,
count = 2,
sampleMessage = "Access denied",
}, wire),
JsonSerializer.Serialize(new AppAnalyticsFailureCode
{
Code = "0x80070005",
ExitCode = 5,
Count = 2,
SampleMessage = "Access denied",
}, wire));
Assert.Equal(
JsonSerializer.Serialize(new
{
manufacturer = "Contoso",
model = "Book 3",
installs = 8,
failed = 2,
failureRate = 25.0,
liftVsBaseline = 1.5,
}, wire),
JsonSerializer.Serialize(new AppDeviceModelBreakdownItem
{
Manufacturer = "Contoso",
Model = "Book 3",
Installs = 8,
Failed = 2,
FailureRate = 25.0,
LiftVsBaseline = 1.5,
}, wire));
}
[Fact]
public void AppSessionsResponse_matches_the_anonymous_shape()
{
var started = new DateTime(2026, 8, 30, 7, 30, 0, DateTimeKind.Utc);
AssertWireIdentical(
new
{
success = true,
total = 1,
offset = 0,
limit = 50,
items = new[]
{
new
{
sessionId = "22222222-2222-2222-2222-222222222222",
tenantId = "11111111-1111-1111-1111-111111111111",
deviceName = "DESKTOP-1",
manufacturer = "Contoso",
model = "Book 3",
appVersion = "1.2.3",
status = "Failed",
installerPhase = "Install",
failureCode = "0x80070005",
exitCode = (int?)5,
attemptNumber = 2,
startedAt = started,
durationSeconds = 90,
installPassCount = 2,
},
},
},
new AppSessionsResponse
{
Success = true,
Total = 1,
Offset = 0,
Limit = 50,
Items = new[]
{
new AppSessionItem
{
SessionId = "22222222-2222-2222-2222-222222222222",
TenantId = "11111111-1111-1111-1111-111111111111",
DeviceName = "DESKTOP-1",
Manufacturer = "Contoso",
Model = "Book 3",
AppVersion = "1.2.3",
Status = "Failed",
InstallerPhase = "Install",
FailureCode = "0x80070005",
ExitCode = 5,
AttemptNumber = 2,
StartedAt = started,
DurationSeconds = 90,
InstallPassCount = 2,
},
},
});
}
}
@@ -47,6 +47,50 @@ public class TypedResponseGuardTests
/// </summary>
private static readonly Dictionary<string, int> WriteBaseline = new(StringComparer.OrdinalIgnoreCase);
// ── Bypass-shape guards (closed 2026-08-31) ─────────────────────────────────────────
// Regexes A/B only see the INLINE literal `...(new { ... })`. Three bypass shapes let a
// response ship untyped anyway and are ratcheted here with EMPTY baselines:
// C: the literal parked in a variable first (`var result = new { ... };` →
// `WriteAsJsonAsync(result)`), which is how rule-stats escaped the 08-31 migration,
// D: hand-serialized bodies (`WriteStringAsync(JsonSerializer.Serialize(new { ... }))`),
// E: builder methods declared `object` / `Task<object>` returning `new { ... }`
// (verdict-calibration's Build). A fourth shape — local `WriteJson(req, object)`
// wrappers — is closed structurally: wrappers take a `T : IApiResponse` generic.
/// <summary>Identifier passed to WriteAsJsonAsync — flagged when the SAME file assigns that identifier an anonymous object.</summary>
private static readonly Regex WriteAsJsonIdentifier =
new(@"WriteAsJsonAsync\(\s*([A-Za-z_]\w*)\s*[,)]", RegexOptions.Compiled);
private static readonly Regex SerializeAnonymous =
new(@"JsonSerializer\.Serialize(?:<[^>]+>)?\(\s*new\s*\{", RegexOptions.Compiled);
private static readonly Regex ObjectReturningBuilder =
new(@"\b(?:object|Task<object>)\s+\w*(?:Build|Compute|Payload|Response)\w*\s*\(", RegexOptions.Compiled);
/// <summary>EMPTY — an anonymous object smuggled through a local variable is the same regression as an inline one.</summary>
private static readonly Dictionary<string, int> IdentifierBaseline = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// JsonSerializer.Serialize(new { ... }) sites that are NOT responses. The single entry is
/// an outgoing HTTP CLIENT request body (App Insights query POST) — not response debt.
/// Everything else must be a typed DTO (error shapes are tolerated by the shape check).
/// </summary>
private static readonly Dictionary<string, int> SerializeBaseline = new(StringComparer.OrdinalIgnoreCase)
{
["Functions/Raw/AppInsightsQueryFunction.cs"] = 1,
};
/// <summary>
/// A Build*/Compute*/*Payload/*Response method declared object hides its wire shape from
/// every type check. The single tolerated site is BuildV2ResponseBody: its SUCCESS arm is
/// the typed <c>SessionDeletionQueuedResponse</c>, but the method returns a union with the
/// anonymous ERROR arms (tolerated by design), so its declared type must stay object.
/// </summary>
private static readonly Dictionary<string, int> ObjectBuilderBaseline = new(StringComparer.OrdinalIgnoreCase)
{
["Functions/Sessions/DeleteSessionFunction.cs"] = 1,
};
[Fact]
public void Anonymous_helper_success_bodies_never_exceed_the_frozen_baseline()
{
@@ -66,6 +110,42 @@ public class TypedResponseGuardTests
"anonymous success WriteAsJsonAsync(new { ... })");
}
[Fact]
public void Anonymous_objects_smuggled_through_a_variable_into_WriteAsJsonAsync_are_flagged()
{
AssertRatchet(
text => WriteAsJsonIdentifier.Matches(text).Count(m =>
{
var ident = m.Groups[1].Value;
if (ident == "new") return false;
var assign = new Regex(@"(?:var\s+)?" + Regex.Escape(ident) + @"\s*=\s*new\s*\{");
var a = assign.Match(text);
// Tolerate error-shaped variables the same way inline literals are tolerated.
return a.Success && !IsErrorShape(text, a.Index + a.Length);
}),
IdentifierBaseline,
"WriteAsJsonAsync(<variable holding an anonymous object>)");
}
[Fact]
public void Anonymous_JsonSerializer_Serialize_success_bodies_never_exceed_the_frozen_baseline()
{
AssertRatchet(
text => SerializeAnonymous.Matches(text)
.Count(m => !IsErrorShape(text, m.Index + m.Length)),
SerializeBaseline,
"anonymous success JsonSerializer.Serialize(new { ... })");
}
[Fact]
public void Object_returning_response_builders_are_flagged()
{
AssertRatchet(
text => ObjectReturningBuilder.Matches(text).Count,
ObjectBuilderBaseline,
"object/Task<object>-returning Build*/Compute*/*Payload/*Response method");
}
/// <summary>
/// Wire DTOs must be flat: System.Text.Json serializes derived properties BEFORE base
/// properties, so any base class would silently reorder JSON keys — and key order is part
@@ -217,7 +217,7 @@ public class VerdictCalibrationTests
private static readonly JsonSerializerOptions Wire = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private static JsonElement BuildJson(IReadOnlyList<VerdictCalibrationDailyAggregate> daily, DateTime today, int days)
=> JsonSerializer.SerializeToElement(VerdictCalibrationResponse.Build(daily, "global", today, days), Wire);
=> JsonSerializer.SerializeToElement(VerdictCalibrationResponseBuilder.Build(daily, "global", today, days), Wire);
[Fact]
public void Response_sums_the_window_and_computes_share_rate_and_trend()
@@ -297,5 +297,5 @@ public class VerdictCalibrationTests
[InlineData("500", 180)]
[InlineData("14", 14)]
public void ClampDays_bounds_the_window(string? raw, int expected)
=> Assert.Equal(expected, VerdictCalibrationResponse.ClampDays(raw));
=> Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw));
}
@@ -750,24 +750,8 @@ public class TenantOffboardFunction
}
}
/// <summary>
/// 202/200 response body for the offboarding endpoint. Fields point the caller at the
/// History row so subsequent reporting / status polling can resolve back to the audit
/// trail. <see cref="EarliestProcessingAt"/> drives the "data deletion starts in mm ss"
/// countdown in the Web UI's drain-barrier state.
/// </summary>
public class OffboardResponse
{
public string TenantId { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string HistoryPartitionKey { get; set; } = string.Empty;
public string HistoryRowKey { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
/// <summary>UTC timestamp before which the worker MUST NOT start Phase 2. Drives the
/// cache-drain-barrier countdown UI. Null on the idempotent-Completed/Failed branches.</summary>
public DateTime? EarliestProcessingAt { get; set; }
}
// OffboardResponse (the 202/200 offboarding body) moved to AutopilotMonitor.Shared.Models
// (Models/OffboardApiModels.cs) so the shared manifest exports it as a wire type.
/// <summary>
/// Decision the resume-path produced; consumed by <see cref="TenantOffboardFunction.HandleExistingMarkerAsync"/>
@@ -128,7 +128,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
/// (so an MCP caller can page a large fleet's app list). <paramref name="nextLinkForOffset"/> builds the
/// route-specific nextLink for the next offset; it is only invoked when more pages remain.
/// </summary>
public static object BuildAppsListResponse(
public static AppsListResponse BuildAppsListResponse(
List<AppInstallSummary> allSummaries,
int days,
int? pageSize = null,
@@ -161,40 +161,40 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var secondHalf = g.Where(s => s.StartedAt >= midpoint).ToList();
var (trend, trendDelta) = ComputeFailureTrend(firstHalf, secondHalf);
return new
return new AppsListItem
{
appName = g.Key,
appType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty,
totalInstalls = total,
succeeded = installed.Count,
skipped,
unmeasured = installed.Count - measured.Count,
failed,
failureRate,
avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
trend,
trendDelta,
lastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt)
AppName = g.Key,
AppType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty,
TotalInstalls = total,
Succeeded = installed.Count,
Skipped = skipped,
Unmeasured = installed.Count - measured.Count,
Failed = failed,
FailureRate = failureRate,
AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
Trend = trend,
TrendDelta = trendDelta,
LastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt)
};
})
.OrderByDescending(a => a.failed)
.ThenByDescending(a => a.failureRate)
.ThenBy(a => a.appName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors
.OrderByDescending(a => a.Failed)
.ThenByDescending(a => a.FailureRate)
.ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors
.ToList();
// Legacy mode: caller did not opt into pagination → full array (web UI pages client-side).
if (pageSize == null)
{
return new
return new AppsListResponse
{
success = true,
totalApps = apps.Count,
totalInstalls = summaries.Count,
collisionExcluded,
windowDays = days,
apps
Success = true,
TotalApps = apps.Count,
TotalInstalls = summaries.Count,
CollisionExcluded = collisionExcluded,
WindowDays = days,
Apps = apps
};
}
@@ -204,18 +204,18 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var nextOffset = offset + page.Count;
var hasMore = nextOffset < apps.Count;
return new
return new AppsListResponse
{
success = true,
totalApps = apps.Count,
totalInstalls = summaries.Count,
collisionExcluded,
windowDays = days,
count = page.Count,
offset,
pageSize = pageSize.Value,
apps = page,
nextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null
Success = true,
TotalApps = apps.Count,
TotalInstalls = summaries.Count,
CollisionExcluded = collisionExcluded,
WindowDays = days,
Count = page.Count,
Offset = offset,
PageSize = pageSize.Value,
Apps = page,
NextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null
};
}
@@ -225,7 +225,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
/// Builds the analytics response body for a single app.
/// Loads sessions individually via the session repository for the device-model join.
/// </summary>
public static async Task<object> BuildAnalyticsResponseAsync(
public static async Task<AppAnalyticsResponse> BuildAnalyticsResponseAsync(
List<AppInstallSummary> allSummaries,
ISessionRepository sessionRepo,
string appName,
@@ -252,38 +252,38 @@ namespace AutopilotMonitor.Functions.Functions.Apps
if (summaries.Count == 0)
{
return new
return new AppAnalyticsResponse
{
success = true,
appName,
appType = string.Empty,
windowDays = days,
collisionExcluded,
bucket = "day",
summary = new
Success = true,
AppName = appName,
AppType = string.Empty,
WindowDays = days,
CollisionExcluded = collisionExcluded,
Bucket = "day",
Summary = new AppAnalyticsSummary
{
totalInstalls = 0,
succeeded = 0,
skipped = 0,
unmeasured = 0,
failed = 0,
failureRate = 0,
avgDurationSeconds = 0,
p95DurationSeconds = 0,
avgDownloadBytes = 0,
trend = "stable",
trendDelta = (double?)null,
flakinessScore = 0.0
TotalInstalls = 0,
Succeeded = 0,
Skipped = 0,
Unmeasured = 0,
Failed = 0,
FailureRate = 0,
AvgDurationSeconds = 0,
P95DurationSeconds = 0,
AvgDownloadBytes = 0,
Trend = "stable",
TrendDelta = null,
FlakinessScore = 0.0
},
timeSeries = Array.Empty<object>(),
versionBreakdown = Array.Empty<object>(),
installerPhaseBreakdown = Array.Empty<object>(),
topFailureCodes = Array.Empty<object>(),
detectionLiesCount = 0,
deviceModelBreakdown = Array.Empty<object>(),
TimeSeries = Array.Empty<AppAnalyticsTimeBucket>(),
VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
DetectionLiesCount = 0,
DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
// Lockstep with the full response: episodes can outlive the window's data
// (e.g. a shrunk days= selection), so the block is still surfaced here.
versionRegressions = appVersionRegressions
VersionRegressions = appVersionRegressions
};
}
@@ -329,38 +329,38 @@ namespace AutopilotMonitor.Functions.Functions.Apps
.Where(MetricsMath.HasMeasuredDuration)
.Select(s => s.DurationSeconds)
.ToList();
return new
return new AppVersionBreakdownItem
{
appVersion = g.Key,
installs = vTotal,
failed = vFailed,
failureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded),
measuredInstalls = vDurations.Count,
medianDurationSeconds = Percentile(vDurations, 0.50),
p95DurationSeconds = Percentile(vDurations, 0.95)
AppVersion = g.Key,
Installs = vTotal,
Failed = vFailed,
FailureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded),
MeasuredInstalls = vDurations.Count,
MedianDurationSeconds = Percentile(vDurations, 0.50),
P95DurationSeconds = Percentile(vDurations, 0.95)
};
})
.OrderByDescending(v => v.installs)
.OrderByDescending(v => v.Installs)
.ToList();
var installerPhaseBreakdown = summaries
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.InstallerPhase))
.GroupBy(s => s.InstallerPhase)
.Select(g => new { phase = g.Key, failed = g.Count() })
.OrderByDescending(p => p.failed)
.Select(g => new AppInstallerPhaseCount { Phase = g.Key, Failed = g.Count() })
.OrderByDescending(p => p.Failed)
.ToList();
var topFailureCodes = summaries
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.FailureCode))
.GroupBy(s => s.FailureCode)
.Select(g => new
.Select(g => new AppAnalyticsFailureCode
{
code = g.Key,
exitCode = g.Select(s => s.ExitCode).FirstOrDefault(e => e.HasValue),
count = g.Count(),
sampleMessage = g.Select(s => s.FailureMessage).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? string.Empty
Code = g.Key,
ExitCode = g.Select(s => s.ExitCode).FirstOrDefault(e => e.HasValue),
Count = g.Count(),
SampleMessage = g.Select(s => s.FailureMessage).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? string.Empty
})
.OrderByDescending(f => f.count)
.OrderByDescending(f => f.Count)
.Take(5)
.ToList();
@@ -397,57 +397,57 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var lift = failureRate > 0
? Math.Round(modelFailureRate / failureRate, 2)
: 0;
return new
return new AppDeviceModelBreakdownItem
{
manufacturer = g.Key.Manufacturer,
model = g.Key.Model,
installs = modelTotal,
failed = modelFailed,
failureRate = modelFailureRate,
liftVsBaseline = lift
Manufacturer = g.Key.Manufacturer,
Model = g.Key.Model,
Installs = modelTotal,
Failed = modelFailed,
FailureRate = modelFailureRate,
LiftVsBaseline = lift
};
})
.OrderByDescending(m => m.liftVsBaseline)
.OrderByDescending(m => m.LiftVsBaseline)
.ToList();
var appType = summaries.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty;
return new
return new AppAnalyticsResponse
{
success = true,
appName,
appType,
windowDays = days,
collisionExcluded,
bucket,
summary = new
Success = true,
AppName = appName,
AppType = appType,
WindowDays = days,
CollisionExcluded = collisionExcluded,
Bucket = bucket,
Summary = new AppAnalyticsSummary
{
totalInstalls = total,
succeeded,
skipped,
unmeasured = installed.Count - measured.Count,
failed,
failureRate,
avgDurationSeconds,
p95DurationSeconds,
avgDownloadBytes,
trend,
trendDelta,
flakinessScore
TotalInstalls = total,
Succeeded = succeeded,
Skipped = skipped,
Unmeasured = installed.Count - measured.Count,
Failed = failed,
FailureRate = failureRate,
AvgDurationSeconds = avgDurationSeconds,
P95DurationSeconds = p95DurationSeconds,
AvgDownloadBytes = avgDownloadBytes,
Trend = trend,
TrendDelta = trendDelta,
FlakinessScore = flakinessScore
},
timeSeries,
versionBreakdown,
installerPhaseBreakdown,
topFailureCodes,
detectionLiesCount,
deviceModelBreakdown,
versionRegressions = appVersionRegressions
TimeSeries = timeSeries,
VersionBreakdown = versionBreakdown,
InstallerPhaseBreakdown = installerPhaseBreakdown,
TopFailureCodes = topFailureCodes,
DetectionLiesCount = detectionLiesCount,
DeviceModelBreakdown = deviceModelBreakdown,
VersionRegressions = appVersionRegressions
};
}
// ── /apps/{appName}/sessions ────────────────────────────────────────
public static async Task<object> BuildSessionsResponseAsync(
public static async Task<AppSessionsResponse> BuildSessionsResponseAsync(
List<AppInstallSummary> allSummaries,
ISessionRepository sessionRepo,
string appName,
@@ -499,34 +499,34 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var items = page.Select(s =>
{
sessionLookup.TryGetValue($"{s.TenantId}|{s.SessionId}", out var sess);
return new
return new AppSessionItem
{
sessionId = s.SessionId,
tenantId = s.TenantId,
deviceName = sess?.DeviceName ?? string.Empty,
manufacturer = sess?.Manufacturer ?? string.Empty,
model = sess?.Model ?? string.Empty,
appVersion = s.AppVersion,
status = s.Status,
installerPhase = s.InstallerPhase,
failureCode = s.FailureCode,
exitCode = s.ExitCode,
attemptNumber = s.AttemptNumber,
startedAt = s.StartedAt,
durationSeconds = s.DurationSeconds,
SessionId = s.SessionId,
TenantId = s.TenantId,
DeviceName = sess?.DeviceName ?? string.Empty,
Manufacturer = sess?.Manufacturer ?? string.Empty,
Model = sess?.Model ?? string.Empty,
AppVersion = s.AppVersion,
Status = s.Status,
InstallerPhase = s.InstallerPhase,
FailureCode = s.FailureCode,
ExitCode = s.ExitCode,
AttemptNumber = s.AttemptNumber,
StartedAt = s.StartedAt,
DurationSeconds = s.DurationSeconds,
// 2+ = the IME processed this app in multiple passes (device-ESP
// evaluation + real install) — explains a completion far after startedAt.
installPassCount = s.InstallPassCount
InstallPassCount = s.InstallPassCount
};
}).ToList();
return new
return new AppSessionsResponse
{
success = true,
total,
offset,
limit,
items
Success = true,
Total = total,
Offset = offset,
Limit = limit,
Items = items
};
}
@@ -559,7 +559,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
return (trend, delta);
}
private static List<object> BuildTimeSeries(List<AppInstallSummary> summaries, DateTime cutoff, DateTime now, string bucket)
private static List<AppAnalyticsTimeBucket> BuildTimeSeries(List<AppInstallSummary> summaries, DateTime cutoff, DateTime now, string bucket)
{
var start = bucket == "week" ? StartOfWeek(cutoff) : cutoff.Date;
var end = now.Date;
@@ -589,14 +589,14 @@ namespace AutopilotMonitor.Functions.Functions.Apps
// PR0 convention: skips are not attempts; durations read measured rows only.
var bInstalled = items.Where(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s)).ToList();
var bMeasured = bInstalled.Where(MetricsMath.HasMeasuredDuration).ToList();
return (object)new
return new AppAnalyticsTimeBucket
{
bucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc),
installs = bTotal,
succeeded = bInstalled.Count,
failed = bFailed,
failureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count),
avgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0
BucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc),
Installs = bTotal,
Succeeded = bInstalled.Count,
Failed = bFailed,
FailureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count),
AvgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0
};
})
.ToList();
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
var json = JsonSerializer.Serialize(new { backupIds = ids }, BackupManifestJson.SerializerOptions);
var json = JsonSerializer.Serialize(new ListBackupsResponse { BackupIds = ids }, BackupManifestJson.SerializerOptions);
await response.WriteStringAsync(json);
return response;
}
@@ -70,56 +70,56 @@ namespace AutopilotMonitor.Functions.Functions.Config
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
/// read-time edition resolution (trial expiry degrades automatically).
/// </summary>
internal static object BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false)
internal static TenantFeatureFlagsResponse BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false)
{
var edition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
var entitlements = FeatureEntitlementCatalog.Get(edition);
var isTrial = edition == TenantEdition.Pro &&
!FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
return new
return new TenantFeatureFlagsResponse
{
// EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive
// Community enable) — field name kept for web compatibility.
bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(config, nowUtc),
BootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(config, nowUtc),
// Session-detail "Collect Logs" button: whether an on-demand diagnostics upload can
// succeed right now (mode not Off + a usable destination). Members below Admin cannot
// read the full config, so this boolean is their only signal. Deliberately exposes no
// destination detail — just "would an upload work".
diagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
DiagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
// Drives the "Autopilot Device Validation disabled" dashboard banner
// (useTenantSecurityConfig).
validateAutopilotDevice = config.ValidateAutopilotDevice,
ValidateAutopilotDevice = config.ValidateAutopilotDevice,
// Dual app-reg self-service migration: when true, running the consent flow (or
// "Detect existing access") targets the NEW app registration and auto-flips this
// tenant's homing after verification — drives the explanatory banner in the
// Autopilot Validation settings section. Non-sensitive: exposes no client ids.
appHomingFunnelActive,
AppHomingFunnelActive = appHomingFunnelActive,
// Session-detail UI flags (useSessionTenantConfig). Nullable in the model;
// surface the agent-side defaults so the UI does not need a second nullable layer.
showScriptOutput = config.ShowScriptOutput ?? true,
enableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false,
enableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true,
ShowScriptOutput = config.ShowScriptOutput ?? true,
EnableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false,
EnableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true,
// Gather-rules page validation indicator. EFFECTIVE value (requires Pro edition +
// GA gate + tenant toggle) — the privileged toggle is UnrestrictedModeEnabled
// (admin-only, stays in the full config response).
unrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(config, nowUtc),
UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(config, nowUtc),
// Edition/entitlement surface (read-time resolution — non-sensitive by design):
// drives the EditionBadge, trial CTA and retention hint in the web UI.
edition = edition.ToString().ToLowerInvariant(),
isTrial,
trialExpiresUtc = isTrial ? config.TrialExpiresUtc : null,
trialAvailable = !config.TrialConsumed && edition == TenantEdition.Community,
Edition = edition.ToString().ToLowerInvariant(),
IsTrial = isTrial,
TrialExpiresUtc = isTrial ? config.TrialExpiresUtc : null,
TrialAvailable = !config.TrialConsumed && edition == TenantEdition.Community,
// Pro-requires-contact surface: drives the trial CTA gate and the dashboard
// "set a contact address" banner for Pro tenants. Boolean only — the address
// itself stays in the admin-gated full config response.
contactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
entitlements = new
ContactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
Entitlements = new TenantFeatureEntitlements
{
retentionCapDays = entitlements.RetentionCapDays,
userRateLimitPerMinute = entitlements.UserRateLimitPerMinute,
delegatedAdminAllowed = entitlements.DelegatedAdminAllowed,
mcpUsagePlan = entitlements.McpUsagePlanName
RetentionCapDays = entitlements.RetentionCapDays,
UserRateLimitPerMinute = entitlements.UserRateLimitPerMinute,
DelegatedAdminAllowed = entitlements.DelegatedAdminAllowed,
McpUsagePlan = entitlements.McpUsagePlanName
}
};
}
@@ -59,19 +59,19 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
/// <c>condition</c> travels as the enum NAME ("Always" | "RealmJoinWatcher" |
/// "DevicePreparation"), never the integer — the web switches on the string.
/// </summary>
internal static object BuildPayload(AdminConfiguration adminConfig) => new
internal static DiagnosticsPathsResponse BuildPayload(AdminConfiguration adminConfig) => new()
{
builtIn = DiagnosticsBuiltInSections.All.Select(s => new
BuiltIn = DiagnosticsBuiltInSections.All.Select(s => new DiagnosticsBuiltInSectionWire
{
id = s.Id,
zipFolder = s.ZipFolder,
sourceFolder = s.SourceFolder,
patterns = s.Patterns,
includeSubfolders = s.IncludeSubfolders,
description = s.Description,
condition = s.Condition.ToString(),
Id = s.Id,
ZipFolder = s.ZipFolder,
SourceFolder = s.SourceFolder,
Patterns = s.Patterns,
IncludeSubfolders = s.IncludeSubfolders,
Description = s.Description,
Condition = s.Condition.ToString(),
}).ToArray(),
globalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(),
GlobalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(),
};
}
}
@@ -58,7 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
// four extra Table reads on every poll from every active user.
var adminConfig = await _adminConfigService.GetConfigurationAsync();
if (!adminConfig.FeedbackEnabled)
return await WriteJson(req, new { eligible = false });
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
// 2. User identity
string tenantId = TenantHelper.GetTenantId(req);
@@ -86,17 +86,17 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
var roleInfo = EntraAppRoleResolver.Resolve(
tableState, tableRole, context.GetUser()?.GetAppRoles(), tenantConfig.EntraAppRolesEnabled);
if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer)
return await WriteJson(req, new { eligible = false });
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
// 4. Tenant age check
if (tenantConfig.OnboardedAt == null ||
(DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays)
return await WriteJson(req, new { eligible = false });
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
// 5. Sessions check — at least 1 session exists.
var sessionPage = sessionsTask.Result;
if (sessionPage.Items.Count == 0)
return await WriteJson(req, new { eligible = false });
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
// 6. Cooldown check
var feedbackEntry = feedbackTask.Result;
@@ -104,14 +104,14 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
{
// Cooldown = 0 means single wave only
if (adminConfig.FeedbackCooldownDays == 0)
return await WriteJson(req, new { eligible = false });
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
var daysSinceInteraction = (DateTime.UtcNow - feedbackEntry.InteractedAt.Value).TotalDays;
if (daysSinceInteraction < adminConfig.FeedbackCooldownDays)
return await WriteJson(req, new { eligible = false });
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
}
return await WriteJson(req, new { eligible = true });
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = true });
}
catch (UnauthorizedAccessException)
{
@@ -122,7 +122,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
{
_logger.LogError(ex, "Error checking feedback status");
// Fail closed — don't show bubble on errors
return await WriteJson(req, new { eligible = false });
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
}
}
@@ -197,7 +197,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
return await WriteJson(req, new { success = true });
return await WriteJson(req, new SuccessOnlyResponse { Success = true });
}
catch (UnauthorizedAccessException)
{
@@ -222,22 +222,22 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
try
{
var allEntries = await _feedbackRepo.GetAllAsync();
var entries = allEntries.Select(e => new
var entries = allEntries.Select(e => new FeedbackEntryWire
{
type = e.Type,
upn = e.Upn,
tenantId = e.TenantId,
displayName = e.DisplayName,
rating = e.Rating,
comment = e.Comment,
dismissed = e.Dismissed,
submitted = e.Submitted,
interactedAt = e.InteractedAt?.ToString("o"),
historyRowKey = e.HistoryRowKey,
domainName = e.DomainName,
Type = e.Type,
Upn = e.Upn,
TenantId = e.TenantId,
DisplayName = e.DisplayName,
Rating = e.Rating,
Comment = e.Comment,
Dismissed = e.Dismissed,
Submitted = e.Submitted,
InteractedAt = e.InteractedAt?.ToString("o"),
HistoryRowKey = e.HistoryRowKey,
DomainName = e.DomainName,
}).ToList();
return await WriteJson(req, new { feedback = entries });
return await WriteJson(req, new FeedbackListResponse { Feedback = entries });
}
catch (Exception ex)
{
@@ -248,7 +248,11 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
}
}
private static async Task<HttpResponseData> WriteJson(HttpRequestData req, object data)
// Generic with an IApiResponse constraint (NOT an IApiResponse parameter): success
// bodies through this wrapper are typed by construction — an anonymous body no longer
// compiles — while the CONCRETE type still reaches the serializer (a declared interface
// type would serialize zero properties under System.Text.Json).
private static async Task<HttpResponseData> WriteJson<T>(HttpRequestData req, T data) where T : IApiResponse
{
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(data);
@@ -129,10 +129,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var days = DeviceJourneyMetricsResponse.ClampDays(query["days"]);
var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponse.BuildAsync(
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync(
_metricsRepo, _sessionRepo, tenantId, days, includeRepeatDevices: true));
return response;
}
@@ -177,11 +177,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var tenantIdFilter = query["tenantId"];
var days = DeviceJourneyMetricsResponse.ClampDays(query["days"]);
var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponse.BuildAsync(
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync(
_metricsRepo, _sessionRepo, partition, days, includeRepeatDevices: partition != "global"));
return response;
}
@@ -195,8 +195,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
}
}
/// <summary>Shared response shape for the tenant and global variants (mirrors TimeAttributionResponse).</summary>
internal static class DeviceJourneyMetricsResponse
/// <summary>
/// Shared response builder for the tenant and global variants (mirrors
/// TimeAttributionResponseBuilder). The envelope/row DTOs
/// (<see cref="DeviceJourneyMetricsResponse"/>, <see cref="DeviceJourneyWindowTotals"/>,
/// <see cref="DeviceJourneyRepeatDevice"/>) live in Shared so the manifest exports them.
/// </summary>
internal static class DeviceJourneyMetricsResponseBuilder
{
internal const int DefaultWindowDays = 30;
internal const int MaxWindowDays = 180; // aggregate retention — older rows no longer exist
@@ -215,7 +220,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
/// </summary>
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
internal static async Task<object> BuildAsync(
internal static async Task<DeviceJourneyMetricsResponse> BuildAsync(
IMetricsRepository metricsRepo, ISessionRepository sessionRepo,
string partition, int days, bool includeRepeatDevices)
{
@@ -223,12 +228,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var daily = await metricsRepo.GetDeviceJourneyAggregatesAsync(partition, InclusiveWindowStart(today, days), today);
var totals = SumAggregates(daily);
List<object>? repeatDevices = null;
List<DeviceJourneyRepeatDevice>? repeatDevices = null;
if (includeRepeatDevices)
{
var histories = await metricsRepo.GetDeviceHistoriesByTenantAsync(partition);
var candidates = SelectRepeatDevices(histories, today.AddDays(-days));
repeatDevices = new List<object>(candidates.Count);
repeatDevices = new List<DeviceJourneyRepeatDevice>(candidates.Count);
foreach (var candidate in candidates)
{
// Failure reason of the newest failed attempt — a bounded point-read per top
@@ -246,44 +251,31 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// fail-soft — the row renders without a reason
}
}
repeatDevices.Add(new
repeatDevices.Add(new DeviceJourneyRepeatDevice
{
serialNumber = candidate.History.SerialNumber,
manufacturer = candidate.History.Manufacturer,
model = candidate.History.Model,
attempts = candidate.History.CurrentJourneyAttempts,
journeyCount = candidate.History.JourneyCount,
lastStatus = candidate.Newest.Status,
lastSessionId = candidate.Newest.SessionId,
lastStartedAt = candidate.Newest.StartedAt,
lastFailureReason,
SerialNumber = candidate.History.SerialNumber,
Manufacturer = candidate.History.Manufacturer,
Model = candidate.History.Model,
Attempts = candidate.History.CurrentJourneyAttempts,
JourneyCount = candidate.History.JourneyCount,
LastStatus = candidate.Newest.Status,
LastSessionId = candidate.Newest.SessionId,
LastStartedAt = candidate.Newest.StartedAt,
LastFailureReason = lastFailureReason,
});
}
}
return new
return new DeviceJourneyMetricsResponse
{
success = true,
windowDays = days,
totals,
daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
repeatDevices,
Success = true,
WindowDays = days,
Totals = totals,
Daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
RepeatDevices = repeatDevices,
};
}
/// <summary>Window totals (serialized camelCase by the worker's global serializer, like the model rows).</summary>
internal sealed class DeviceJourneyWindowTotals
{
public int CompletedJourneys { get; init; }
public int FirstTimeRight { get; init; }
/// <summary>Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1).</summary>
public double? FtrRatePct { get; init; }
public int ExcludedSessions { get; init; }
public List<DeviceJourneyAttemptBucket> AttemptHistogram { get; init; } = new();
}
/// <summary>
/// Window sums over the daily rows — honest because every field is an additive count
/// (rule: the FTR window rate IS the ratio of summed counts). internal static: pinned
@@ -66,11 +66,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
/// Global scope sees every row, unconfirmed ones included — that is where a bogus claim
/// is investigated.
///
/// Returned as <see cref="object"/> on purpose — System.Text.Json serializes an
/// `object` declared type using the RUNTIME type, so both branches keep the exact
/// wire format they had while the projection was inline.
/// Returned as <c>IReadOnlyList&lt;object&gt;</c> — System.Text.Json serializes each
/// element using its RUNTIME type, so both branches keep the exact wire format they
/// had while the projection was inline. Both element types are manifest-exported
/// (<see cref="ImeVersionHistoryEntry"/> / <see cref="ImeVersionHistoryLeanEntry"/>).
/// </summary>
public static object BuildResponsePayload(
public static IReadOnlyList<object> BuildResponsePayload(
IEnumerable<ImeVersionHistoryEntry> versions, bool hasGlobalScope)
{
if (hasGlobalScope)
@@ -80,7 +81,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return versions
.Where(IsFleetConfirmed)
.Select(v => new { v.Version, v.FirstSeenAt, v.LastSeenAt, v.SessionCount })
.Select(v => new ImeVersionHistoryLeanEntry
{
Version = v.Version,
FirstSeenAt = v.FirstSeenAt,
LastSeenAt = v.LastSeenAt,
SessionCount = v.SessionCount,
})
.ToList();
}
@@ -93,7 +93,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(await TimeAttributionResponse.BuildAsync(_metricsRepo, tenantId));
await response.WriteAsJsonAsync(await TimeAttributionResponseBuilder.BuildAsync(_metricsRepo, tenantId));
return response;
}
catch (Exception ex)
@@ -132,7 +132,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(await TimeAttributionResponse.BuildAsync(_metricsRepo, partition));
await response.WriteAsJsonAsync(await TimeAttributionResponseBuilder.BuildAsync(_metricsRepo, partition));
return response;
}
catch (Exception ex)
@@ -145,8 +145,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
}
}
/// <summary>Shared response shape for the tenant and global variants (mirrors the MetricsMath single-source pattern).</summary>
internal static class TimeAttributionResponse
/// <summary>
/// Shared response builder for the tenant and global variants (mirrors the MetricsMath
/// single-source pattern). The envelope DTO (<see cref="TimeAttributionMetricsResponse"/>)
/// lives in Shared so the manifest exports it.
/// </summary>
internal static class TimeAttributionResponseBuilder
{
internal const int WindowDays = 30;
@@ -164,7 +168,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
/// </summary>
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
internal static async Task<object> BuildAsync(IMetricsRepository metricsRepo, string partition)
internal static async Task<TimeAttributionMetricsResponse> BuildAsync(IMetricsRepository metricsRepo, string partition)
{
var freshCutoff = DateTime.UtcNow - RollingMaxAge;
var rolling = (await metricsRepo.GetRollingTimeAttributionAggregatesAsync(partition))
@@ -173,14 +177,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var today = DateTime.UtcNow.Date;
var daily = await metricsRepo.GetTimeAttributionAggregatesAsync(partition, InclusiveWindowStart(today, WindowDays), today);
return new
return new TimeAttributionMetricsResponse
{
success = true,
windowDays = WindowDays,
Success = true,
WindowDays = WindowDays,
// Range statistics per enrollment class (never mixed) — the UI gates rendering
// at cleanSessionCount >= 20 and shows "insufficient data (n=…)" below it.
classes = rolling.OrderBy(r => r.EnrollmentClass, StringComparer.Ordinal).ToList(),
daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
Classes = rolling.OrderBy(r => r.EnrollmentClass, StringComparer.Ordinal).ToList(),
Daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
};
}
}
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var tenantIdFilter = query["tenantId"];
var days = VerdictCalibrationResponse.ClampDays(query["days"]);
var days = VerdictCalibrationResponseBuilder.ClampDays(query["days"]);
var partition = "global";
if (!string.IsNullOrWhiteSpace(tenantIdFilter))
{
@@ -57,14 +57,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var today = DateTime.UtcNow.Date;
// One read covers both the requested window and the trend horizon (7d window + 28d baseline).
var readDays = Math.Max(days, VerdictCalibrationResponse.TrendHorizonDays);
var readDays = Math.Max(days, VerdictCalibrationResponseBuilder.TrendHorizonDays);
var daily = await _metricsRepo.GetVerdictCalibrationAggregatesAsync(
partition, VerdictCalibrationResponse.InclusiveWindowStart(today, readDays), today);
partition, VerdictCalibrationResponseBuilder.InclusiveWindowStart(today, readDays), today);
// Active drift episodes of this partition (tracker keyspace; fail-soft empty).
var alerts = await _tracker.GetVerdictCalibrationAlertsAsync(partition);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(VerdictCalibrationResponse.Build(daily, partition, today, days, alerts));
await response.WriteAsJsonAsync(VerdictCalibrationResponseBuilder.Build(daily, partition, today, days, alerts));
return response;
}
catch (Exception ex)
@@ -77,8 +77,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
}
}
/// <summary>Pure response builder — internal static so the matrix arithmetic is pinned by tests.</summary>
internal static class VerdictCalibrationResponse
/// <summary>
/// Pure response builder — internal static so the matrix arithmetic is pinned by tests.
/// The row/envelope DTOs live in AutopilotMonitor.Shared.Models
/// (Models/Metrics/VerdictCalibrationApiModels.cs) so the manifest exports them.
/// </summary>
internal static class VerdictCalibrationResponseBuilder
{
internal const int DefaultWindowDays = 30;
internal const int MaxWindowDays = 180; // aggregate retention
@@ -95,37 +99,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
/// <summary>"Last N days" = exactly N calendar day keys including today (both range ends inclusive).</summary>
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
internal sealed class PathRow
{
public string VerdictPath { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public int Count { get; set; }
public double SharePct { get; set; }
public int DerivedCount { get; set; }
public int Eligible7d { get; set; }
public int ReEnrolled7d { get; set; }
/// <summary>Null below the minimum eligible count — never a rate on a handful of sessions.</summary>
public double? ReEnrollRatePct { get; set; }
public int OverriddenByAdmin { get; set; }
public int OverriddenByLateCompletion { get; set; }
public int OverriddenOther { get; set; }
public TrendWindow Window7 { get; set; } = new();
public TrendWindow Baseline28 { get; set; } = new();
/// <summary>Window share ÷ baseline share; null when the baseline share is 0 (a new path has no finite lift — never invented).</summary>
public double? Lift { get; set; }
}
internal sealed class TrendWindow
{
public int Count { get; set; }
public int Sessions { get; set; }
public double SharePct { get; set; }
}
/// <summary>Minimum eligible sessions before a re-enrollment rate is stated (truthfulness: no rate on n&lt;20).</summary>
internal const int MinEligibleForRate = 20;
internal static object Build(
internal static VerdictCalibrationResponse Build(
IReadOnlyList<VerdictCalibrationDailyAggregate> daily, string partition, DateTime today, int days,
IReadOnlyList<VerdictCalibrationAlert>? alerts = null)
{
@@ -133,18 +110,18 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var trendWindowStart = Key(InclusiveWindowStart(today, TrendWindowDays));
var baselineStart = Key(InclusiveWindowStart(today, TrendHorizonDays));
var rows = new Dictionary<(string Path, string Status), PathRow>();
var rows = new Dictionary<(string Path, string Status), VerdictCalibrationPathRow>();
int sessions = 0, terminal = 0, derived = 0;
int trendSessions = 0, baselineSessions = 0;
DateTime? computedAt = null;
var versions = new HashSet<int>();
PathRow Row(VerdictCalibrationBucket b)
VerdictCalibrationPathRow Row(VerdictCalibrationBucket b)
{
var key = (b.VerdictPath, b.Status);
if (!rows.TryGetValue(key, out var row))
{
row = new PathRow { VerdictPath = b.VerdictPath, Status = b.Status };
row = new VerdictCalibrationPathRow { VerdictPath = b.VerdictPath, Status = b.Status };
rows[key] = row;
}
return row;
@@ -206,20 +183,32 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
: null;
}
return new
return new VerdictCalibrationResponse
{
success = true,
tenantId = partition,
windowDays = days,
windowStart,
windowEnd = Key(today),
computedAt,
versions = versions.OrderBy(v => v).ToArray(),
totals = new { sessions, terminal, derived, days = daily.Count(d => string.CompareOrdinal(d.Date, windowStart) >= 0) },
trend = new { windowDays = TrendWindowDays, baselineDays = TrendBaselineDays, windowSessions = trendSessions, baselineSessions },
paths = list,
Success = true,
TenantId = partition,
WindowDays = days,
WindowStart = windowStart,
WindowEnd = Key(today),
ComputedAt = computedAt,
Versions = versions.OrderBy(v => v).ToArray(),
Totals = new VerdictCalibrationTotals
{
Sessions = sessions,
Terminal = terminal,
Derived = derived,
Days = daily.Count(d => string.CompareOrdinal(d.Date, windowStart) >= 0),
},
Trend = new VerdictCalibrationTrendMeta
{
WindowDays = TrendWindowDays,
BaselineDays = TrendBaselineDays,
WindowSessions = trendSessions,
BaselineSessions = baselineSessions,
},
Paths = list,
// Wording contract shared with the rule radar: a dimension is correlated, not causal.
alerts = (alerts ?? Array.Empty<VerdictCalibrationAlert>())
Alerts = (alerts ?? Array.Empty<VerdictCalibrationAlert>())
.OrderByDescending(a => a.FirstNotifiedAt)
.ToList(),
};
@@ -1,6 +1,7 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -29,7 +30,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
}
/// <summary>
/// GET /api/metrics/rule-stats?startDate=...&endDate=...&ruleType=analyze
/// GET /api/metrics/rule-stats?startDate=...&amp;endDate=...&amp;ruleType=analyze
/// Returns rule stats for the caller's tenant (MemberRead).
/// </summary>
[Function("GetRuleStats")]
@@ -48,55 +49,16 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
tenantId, startDate, endDate);
var entries = await _metricsRepo.GetRuleStatsAsync(tenantId, startDate, endDate, ruleType);
var aggregated = BuildRuleAggregates(entries);
// Aggregate across dates per ruleId
var aggregated = entries
.GroupBy(e => e.RuleId)
.Select(g => new
{
ruleId = g.Key,
ruleType = g.First().RuleType,
ruleTitle = g.First().RuleTitle,
category = g.First().Category,
severity = g.First().Severity,
fireCount = g.Sum(e => e.FireCount),
evaluationCount = g.Sum(e => e.EvaluationCount),
sessionsEvaluated = g.Sum(e => e.SessionsEvaluated),
hitRate = g.Sum(e => e.EvaluationCount) > 0
? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1)
: 0.0,
avgConfidenceScore = g.Sum(e => e.FireCount) > 0
? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1)
: 0.0,
trend = g.OrderBy(e => e.Date).Select(e => new
{
date = e.Date,
fireCount = e.FireCount,
evaluationCount = e.EvaluationCount
})
})
.OrderByDescending(r => r.fireCount)
.ToList();
var totalEvaluations = aggregated.Sum(r => r.evaluationCount);
var totalFires = aggregated.Sum(r => r.fireCount);
var result = new
var result = new RuleStatsResponse
{
rules = aggregated,
Rules = aggregated,
// F3 PR6: active regression episodes (tracker rows) — the rules-page badge
// and MCP get_rule_stats read them from here. Empty list = nothing regressed.
regressions = await _notificationTracker.GetRuleRegressionsAsync(tenantId),
summary = new
{
totalEvaluations,
totalFires,
overallHitRate = totalEvaluations > 0
? Math.Round(100.0 * totalFires / totalEvaluations, 1)
: 0.0,
topRuleByFireCount = aggregated.FirstOrDefault()?.ruleId,
period = new { start = startDate, end = endDate }
}
Regressions = await _notificationTracker.GetRuleRegressionsAsync(tenantId),
// The tenant route historically carries no uniqueRules key — keep it absent.
Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: null),
};
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -113,7 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
}
/// <summary>
/// GET /api/global/metrics/rule-stats?startDate=...&endDate=...&ruleType=analyze&tenantId=...
/// GET /api/global/metrics/rule-stats?startDate=...&amp;endDate=...&amp;ruleType=analyze&amp;tenantId=...
/// Returns cross-tenant global rule stats (Global Admin only).
/// When tenantId is provided, returns tenant-specific stats instead of global aggregates.
/// </summary>
@@ -136,57 +98,17 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
queryTenantId, startDate, endDate);
var entries = await _metricsRepo.GetRuleStatsAsync(queryTenantId, startDate, endDate, ruleType);
var aggregated = BuildRuleAggregates(entries);
var aggregated = entries
.GroupBy(e => e.RuleId)
.Select(g => new
{
ruleId = g.Key,
ruleType = g.First().RuleType,
ruleTitle = g.First().RuleTitle,
category = g.First().Category,
severity = g.First().Severity,
fireCount = g.Sum(e => e.FireCount),
evaluationCount = g.Sum(e => e.EvaluationCount),
sessionsEvaluated = g.Sum(e => e.SessionsEvaluated),
hitRate = g.Sum(e => e.EvaluationCount) > 0
? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1)
: 0.0,
avgConfidenceScore = g.Sum(e => e.FireCount) > 0
? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1)
: 0.0,
trend = g.OrderBy(e => e.Date).Select(e => new
{
date = e.Date,
fireCount = e.FireCount,
evaluationCount = e.EvaluationCount
})
})
.OrderByDescending(r => r.fireCount)
.ToList();
var totalEvaluations = aggregated.Sum(r => r.evaluationCount);
var totalFires = aggregated.Sum(r => r.fireCount);
var result = new
var result = new RuleStatsResponse
{
rules = aggregated,
Rules = aggregated,
// Regression episodes are tenant-scoped: present when drilling into one
// tenant, empty for the cross-tenant "global" aggregate (no episode there).
regressions = queryTenantId != "global"
Regressions = queryTenantId != "global"
? await _notificationTracker.GetRuleRegressionsAsync(queryTenantId)
: new List<AutopilotMonitor.Shared.Models.RuleRegressionAlert>(),
summary = new
{
totalEvaluations,
totalFires,
overallHitRate = totalEvaluations > 0
? Math.Round(100.0 * totalFires / totalEvaluations, 1)
: 0.0,
topRuleByFireCount = aggregated.FirstOrDefault()?.ruleId,
uniqueRules = aggregated.Count,
period = new { start = startDate, end = endDate }
}
: new List<RuleRegressionAlert>(),
Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: aggregated.Count),
};
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -201,5 +123,53 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return errorResponse;
}
}
/// <summary>Aggregate the per-date entries across dates per ruleId, fires descending.</summary>
internal static List<RuleStatsRuleAggregate> BuildRuleAggregates(IReadOnlyList<RuleStatsEntry> entries)
=> entries
.GroupBy(e => e.RuleId)
.Select(g => new RuleStatsRuleAggregate
{
RuleId = g.Key,
RuleType = g.First().RuleType,
RuleTitle = g.First().RuleTitle,
Category = g.First().Category,
Severity = g.First().Severity,
FireCount = g.Sum(e => e.FireCount),
EvaluationCount = g.Sum(e => e.EvaluationCount),
SessionsEvaluated = g.Sum(e => e.SessionsEvaluated),
HitRate = g.Sum(e => e.EvaluationCount) > 0
? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1)
: 0.0,
AvgConfidenceScore = g.Sum(e => e.FireCount) > 0
? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1)
: 0.0,
Trend = g.OrderBy(e => e.Date).Select(e => new RuleTrendPoint
{
Date = e.Date,
FireCount = e.FireCount,
EvaluationCount = e.EvaluationCount
}).ToList(),
})
.OrderByDescending(r => r.FireCount)
.ToList();
internal static RuleStatsSummary BuildSummary(
IReadOnlyList<RuleStatsRuleAggregate> aggregated, string startDate, string endDate, int? uniqueRules)
{
var totalEvaluations = aggregated.Sum(r => r.EvaluationCount);
var totalFires = aggregated.Sum(r => r.FireCount);
return new RuleStatsSummary
{
TotalEvaluations = totalEvaluations,
TotalFires = totalFires,
OverallHitRate = totalEvaluations > 0
? Math.Round(100.0 * totalFires / totalEvaluations, 1)
: 0.0,
TopRuleByFireCount = aggregated.Count > 0 ? aggregated[0].RuleId : null,
UniqueRules = uniqueRules,
Period = new RuleStatsPeriod { Start = startDate, End = endDate },
};
}
}
}
@@ -2,6 +2,7 @@ using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Deletion;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -172,12 +173,12 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
/// </summary>
internal static object BuildV2ResponseBody(SessionDeletionEnqueueResult result, string sessionId) => result.Outcome switch
{
SessionDeletionEnqueueOutcome.Enqueued => new
SessionDeletionEnqueueOutcome.Enqueued => new SessionDeletionQueuedResponse
{
success = true,
status = "queued",
manifestId = result.ManifestId,
message = "Cascade deletion queued; worker will drain asynchronously.",
Success = true,
Status = "queued",
ManifestId = result.ManifestId,
Message = "Cascade deletion queued; worker will drain asynchronously.",
},
SessionDeletionEnqueueOutcome.AlreadyInFlight => new
{
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -170,19 +171,19 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
{
var status = MapOutcomeToStatus(result.Outcome);
var body = new
var body = new SessionRestoreResponse
{
success = status == HttpStatusCode.OK,
outcome = result.Outcome.ToString(),
mode = result.Mode,
message = result.Message,
currentState = result.CurrentState,
pendingManifestId = result.PendingManifestId,
rowsRestoredByTable = result.RowsRestoredByTable,
rowsSkippedByTable = result.RowsSkippedByTable,
wouldRestoreByTable = result.WouldRestoreByTable,
inventoryReIncrements = result.InventoryReIncrements,
durationMs = result.DurationMs,
Success = status == HttpStatusCode.OK,
Outcome = result.Outcome.ToString(),
Mode = result.Mode,
Message = result.Message,
CurrentState = result.CurrentState,
PendingManifestId = result.PendingManifestId,
RowsRestoredByTable = result.RowsRestoredByTable,
RowsSkippedByTable = result.RowsSkippedByTable,
WouldRestoreByTable = result.WouldRestoreByTable,
InventoryReIncrements = result.InventoryReIncrements,
DurationMs = result.DurationMs,
};
return await WriteJsonAsync(req, status, body);
@@ -71,7 +71,7 @@ public static class MetricsMath
/// final status. Peer bytes and Microsoft Connected Cache (MCC) bytes are reported separately —
/// MCC is counted apart from peers by DO — and offload% credits both as "not pulled from the CDN".
/// </summary>
public static object BuildAppMetricsPayload(IEnumerable<AppInstallSummary> summaries)
public static AppMetricsResponse BuildAppMetricsPayload(IEnumerable<AppInstallSummary> summaries)
{
var summaryList = summaries as IList<AppInstallSummary> ?? summaries.ToList();
@@ -101,65 +101,66 @@ public static class MetricsMath
// row reports source bytes but no DoTotalBytesDownloaded — so that telemetry is not lost.
var doG = DoAggregator.Compute(g);
return new
return new AppMetricsAppGroup
{
appName = g.Key,
totalInstalls = total,
succeeded = installed.Count,
skipped = skipped.Count,
unmeasured = installed.Count - measured.Count,
failed = failed.Count,
AppName = g.Key,
TotalInstalls = total,
Succeeded = installed.Count,
Skipped = skipped.Count,
Unmeasured = installed.Count - measured.Count,
Failed = failed.Count,
// Skips leave the rate (they are not attempts); legacy rows stay in it.
failureRate = TerminalFailureRatePct(failed.Count, installed.Count),
avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
measuredInstalls = measured.Count,
avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
doTotalBytesDownloaded = doG.TotalBytesDownloaded,
doBytesFromPeers = doG.BytesFromPeers,
doBytesFromCacheServer = doG.BytesFromCacheServer,
doBytesFromHttp = doG.BytesFromHttp,
peerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded),
topFailureCodes = failed
FailureRate = TerminalFailureRatePct(failed.Count, installed.Count),
AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
MeasuredInstalls = measured.Count,
AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
DoTotalBytesDownloaded = doG.TotalBytesDownloaded,
DoBytesFromPeers = doG.BytesFromPeers,
DoBytesFromCacheServer = doG.BytesFromCacheServer,
DoBytesFromHttp = doG.BytesFromHttp,
PeerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded),
TopFailureCodes = failed
.Where(f => !string.IsNullOrEmpty(f.FailureCode))
.GroupBy(f => f.FailureCode)
.OrderByDescending(fc => fc.Count())
.Take(3)
.Select(fc => new { code = fc.Key, count = fc.Count() })
.Select(fc => new AppFailureCodeCount { Code = fc.Key, Count = fc.Count() })
.ToList()
};
}).ToList();
// Slowest ranking gates on MEASURED installs — an app whose durations were mostly
// unobserved must not rank as "fast" on a handful of zeros (audit §0.5).
var slowestApps = SelectSlowestApps(
appGroups, a => a.measuredInstalls, a => (double)a.avgDurationSeconds, minSamples: 3, take: 10);
appGroups, a => a.MeasuredInstalls, a => a.AvgDurationSeconds, minSamples: 3, take: 10);
var topFailingApps = appGroups
.Where(a => a.failed > 0)
.OrderByDescending(a => a.failed)
.ThenByDescending(a => a.failureRate)
.Where(a => a.Failed > 0)
.OrderByDescending(a => a.Failed)
.ThenByDescending(a => a.FailureRate)
.Take(10)
.ToList();
var doAll = DoAggregator.Compute(summaryList);
return new
return new AppMetricsResponse
{
success = true,
totalApps = appGroups.Count,
totalInstalls = summaryList.Count,
totalSkipped = appGroups.Sum(a => a.skipped),
totalUnmeasured = appGroups.Sum(a => a.unmeasured),
totalCollisionExcluded,
slowestApps,
topFailingApps,
deliveryOptimization = new
Success = true,
TotalApps = appGroups.Count,
TotalInstalls = summaryList.Count,
TotalSkipped = appGroups.Sum(a => a.Skipped),
TotalUnmeasured = appGroups.Sum(a => a.Unmeasured),
TotalCollisionExcluded = totalCollisionExcluded,
SlowestApps = slowestApps,
TopFailingApps = topFailingApps,
DeliveryOptimization = new AppMetricsDeliveryOptimization
{
totalBytesDownloaded = doAll.TotalBytesDownloaded,
fromPeers = doAll.BytesFromPeers,
fromCacheServer = doAll.BytesFromCacheServer,
fromHttp = doAll.BytesFromHttp,
peerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded),
TotalBytesDownloaded = doAll.TotalBytesDownloaded,
FromPeers = doAll.BytesFromPeers,
FromCacheServer = doAll.BytesFromCacheServer,
FromHttp = doAll.BytesFromHttp,
PeerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded),
}
};
}
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Functions.Services
// Shallow copy so flipping FromCache never mutates the instance an earlier
// (fresh) caller may still be holding/serializing. The buckets are shared
// read-only — nothing downstream writes into a response.
return entry.metrics.CloneAsCacheHit();
return CloneAsCacheHit(entry.metrics);
}
}
@@ -287,6 +287,21 @@ namespace AutopilotMonitor.Functions.Services
}));
}
/// <summary>Shallow copy flagged as a cache hit — see the cache-read path.</summary>
private static AgentEfficiencyMetricsResponse CloneAsCacheHit(AgentEfficiencyMetricsResponse source) => new()
{
WindowDays = source.WindowDays,
SessionLimit = source.SessionLimit,
TenantId = source.TenantId,
SessionsScanned = source.SessionsScanned,
SessionsWithSnapshots = source.SessionsWithSnapshots,
ByVersion = source.ByVersion,
Overall = source.Overall,
ComputedAt = source.ComputedAt,
ComputeDurationMs = source.ComputeDurationMs,
FromCache = true
};
private static PercentileStats? BuildStats(IEnumerable<double> values)
{
var sorted = values.OrderBy(v => v).ToList();
@@ -303,76 +318,7 @@ namespace AutopilotMonitor.Functions.Services
}
}
// ── Response DTOs ────────────────────────────────────────────────────────────
public class AgentEfficiencyMetricsResponse
{
public int WindowDays { get; set; }
public int SessionLimit { get; set; }
/// <summary>Echo of the requested tenant filter; null = cross-tenant aggregate.</summary>
public string? TenantId { get; set; }
/// <summary>Sessions the scan covered — compare against <see cref="SessionLimit"/> for truncation.</summary>
public int SessionsScanned { get; set; }
public int SessionsWithSnapshots { get; set; }
public List<AgentVersionEfficiency> ByVersion { get; set; } = new();
public AgentVersionEfficiency? Overall { get; set; }
public DateTime ComputedAt { get; set; }
public int ComputeDurationMs { get; set; }
public bool FromCache { get; set; }
/// <summary>Shallow copy flagged as a cache hit — see the cache-read path.</summary>
internal AgentEfficiencyMetricsResponse CloneAsCacheHit() => new()
{
WindowDays = WindowDays,
SessionLimit = SessionLimit,
TenantId = TenantId,
SessionsScanned = SessionsScanned,
SessionsWithSnapshots = SessionsWithSnapshots,
ByVersion = ByVersion,
Overall = Overall,
ComputedAt = ComputedAt,
ComputeDurationMs = ComputeDurationMs,
FromCache = true
};
}
public class AgentVersionEfficiency
{
/// <summary>Null on the cross-version "overall" bucket (omitted on the wire).</summary>
public string? AgentVersion { get; set; }
public int SessionsScanned { get; set; }
public int SessionsWithSnapshots { get; set; }
public int SpoolPressureSessions { get; set; }
public PercentileStats? AvgCpuPercent { get; set; }
public PercentileStats? MaxCpuPercent { get; set; }
public PercentileStats? MaxWorkingSetMb { get; set; }
public PercentileStats? MaxPrivateBytesMb { get; set; }
public PercentileStats? MaxThreadCount { get; set; }
public PercentileStats? MaxHandleCount { get; set; }
public PercentileStats? MaxSpoolDepth { get; set; }
public PercentileStats? MaxSpoolFileBytes { get; set; }
public PercentileStats? ApiLatencyMs { get; set; }
public PercentileStats? ApiRequestCount { get; set; }
public CrashRateMetrics? CrashRate { get; set; }
public List<EfficiencyOffender>? TopOffenders { get; set; }
}
/// <summary>Distribution of a per-session statistic across a version bucket.</summary>
public class PercentileStats
{
public double P50 { get; set; }
public double P95 { get; set; }
public double Max { get; set; }
public double Avg { get; set; }
public int SampleCount { get; set; }
}
public class EfficiencyOffender
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string? DeviceName { get; set; }
public string Dimension { get; set; } = string.Empty;
public double Value { get; set; }
}
// Response DTOs (AgentEfficiencyMetricsResponse family) moved to
// AutopilotMonitor.Shared.Models (Models/Metrics/AgentPerformanceMetrics.cs) so the
// shared manifest exports them as wire types.
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services
{
@@ -328,81 +328,7 @@ namespace AutopilotMonitor.Functions.Services
=> AgentMetricsAggregation.GetString(data, key);
}
// ── Response DTOs ────────────────────────────────────────────────────────────
public class PlatformAgentMetricsResponse
{
public List<SessionAgentMetric> Sessions { get; set; } = new();
public DeliveryLatencyMetrics? DeliveryLatency { get; set; }
public CrashRateMetrics? CrashRate { get; set; }
public DateTime ComputedAt { get; set; }
public int ComputeDurationMs { get; set; }
public bool FromCache { get; set; }
public int WindowDays { get; set; }
public int SessionLimit { get; set; }
/// <summary>
/// Sessions the scan actually covered in the window (before the has-snapshots filter
/// that shapes <see cref="Sessions"/>). Callers must compare THIS against
/// <see cref="SessionLimit"/> to decide whether the window was truncated —
/// <c>Sessions.Count</c> understates truncation on fleets where many sessions
/// emit no agent_metrics_snapshot.
/// </summary>
public int SessionsScanned { get; set; }
}
public class SessionAgentMetric
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string? DeviceName { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? StartedAt { get; set; }
public string? Status { get; set; }
public string? AgentVersion { get; set; }
public int SnapshotCount { get; set; }
public double TotalBytesUp { get; set; }
public double TotalBytesDown { get; set; }
public double TotalRequests { get; set; }
public double AvgCpu { get; set; }
public double MaxCpu { get; set; }
public double AvgWorkingSet { get; set; }
public double MaxWorkingSet { get; set; }
public double AvgPrivateBytes { get; set; }
public double AvgLatency { get; set; }
public double AvgSpoolDepth { get; set; }
public double MaxSpoolDepth { get; set; }
public double PeakSpoolDepth { get; set; }
public double MaxSpoolFileBytes { get; set; }
public double TotalEventsEmitted { get; set; }
public bool SpoolPressureDetected { get; set; }
}
public class DeliveryLatencyMetrics
{
public double P50Ms { get; set; }
public double P95Ms { get; set; }
public double P99Ms { get; set; }
public double AvgMs { get; set; }
public int SampleCount { get; set; }
public double ClockSkewPercent { get; set; }
}
public class CrashRateMetrics
{
public int TotalStarts { get; set; }
public int CleanExits { get; set; }
public int ExceptionCrashes { get; set; }
public int HardKills { get; set; }
public int RebootKills { get; set; }
public int FirstRuns { get; set; }
public double CrashRatePercent { get; set; }
public List<CrashExceptionSummary> TopExceptions { get; set; } = new();
}
public class CrashExceptionSummary
{
public string ExceptionType { get; set; } = string.Empty;
public int Count { get; set; }
}
// Response DTOs (PlatformAgentMetricsResponse family) moved to
// AutopilotMonitor.Shared.Models (Models/Metrics/AgentPerformanceMetrics.cs) so the
// shared manifest exports them as wire types.
}
File diff suppressed because it is too large Load Diff
@@ -14,8 +14,12 @@ import type {
GetMcpUserUsageResponse,
GetMyMcpUsageResponse,
GetUnmatchedSoftwareResponse,
PlatformAgentMetricsResponse,
QueryRawTableResponse,
RuleStatsResponse,
RuleStatsRuleAggregate,
SoftwareInventoryResponse,
VerdictCalibrationResponse,
} from '../generated/wire-types.generated.js';
/**
@@ -457,31 +461,12 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
},
async (args) => withToolTelemetry('get_platform_metrics', args, async () => {
try {
type SessionMetric = {
sessionId: string; tenantId: string; deviceName?: string; model?: string; status?: string;
agentVersion?: string; snapshotCount: number;
avgCpu: number; maxCpu: number; avgWorkingSet: number; maxWorkingSet: number;
avgPrivateBytes: number; avgLatency: number;
totalBytesUp: number; totalBytesDown: number; totalRequests: number;
avgSpoolDepth: number; maxSpoolDepth: number; peakSpoolDepth: number;
maxSpoolFileBytes: number; totalEventsEmitted: number; spoolPressureDetected: boolean;
};
// Backend fans out one storage query per session (bounded to 32 concurrent), so
// 2000-session windows take longer than the default 30s client timeout — same
// override precedent as get_session_summary. Warm (cached) calls return instantly.
const raw = await apiFetch(
`/api/global/metrics/platform${buildQuery({ days: args.days, limit: args.maxSessions })}`,
{ signal: AbortSignal.timeout(90_000) }) as
{
sessions?: SessionMetric[]; windowDays?: number; sessionLimit?: number; sessionsScanned?: number;
deliveryLatency?: { p50Ms: number; p95Ms: number; p99Ms: number; avgMs: number; sampleCount: number; clockSkewPercent: number };
crashRate?: {
totalStarts: number; cleanExits: number; exceptionCrashes: number; hardKills: number;
rebootKills: number; firstRuns: number; crashRatePercent: number;
topExceptions?: { exceptionType: string; count: number }[];
};
computedAt?: string; computeDurationMs?: number; fromCache?: boolean;
};
{ signal: AbortSignal.timeout(90_000) }) as PlatformAgentMetricsResponse;
const sessions = raw?.sessions ?? [];
const requested = { days: args.days, limit: args.maxSessions };
// Backend-computed blocks that exist independently of snapshot availability
@@ -898,7 +883,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
async (args) => withToolTelemetry('get_verdict_calibration', args, async () => {
try {
const { tenantId, days, minSharePct, top } = args;
const data = await apiFetch(`/api/global/metrics/verdict-calibration${buildQuery({ tenantId, days })}`);
const data = await apiFetch(`/api/global/metrics/verdict-calibration${buildQuery({ tenantId, days })}`) as VerdictCalibrationResponse;
return toolResultText(shapeVerdictCalibration(data, { minSharePct, top }), MAX_RESULT_SIZE_CHARS.adminStream);
} catch (error: unknown) {
return toolError('get_verdict_calibration', args, error);
@@ -1490,15 +1475,15 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
};
if (tenantId) params.tenantId = tenantId;
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`) as {
rules?: Array<Record<string, unknown>>;
trendsNote?: string;
};
const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`) as
RuleStatsResponse & { trendsNote?: string };
// The per-rule daily trend arrays are the size driver (one row per rule
// per day; a 30-day platform-scope response reaches 150k+ chars and gets
// host-truncated). Aggregates stay intact — trends only on request.
// (Dropping `trend` and adding `trendsNote` is this tool's OUTPUT contract,
// layered on the wire type — hence the Partial view for the delete.)
if (!args.includeTrends && Array.isArray(data?.rules)) {
for (const rule of data.rules) delete rule.trend;
for (const rule of data.rules) delete (rule as Partial<RuleStatsRuleAggregate>).trend;
data.trendsNote = 'Per-rule daily trend rows omitted. Re-call with includeTrends=true (plus a narrow startDate/endDate window) to include them.';
}
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
@@ -12,21 +12,23 @@
* in `omitted` so a filtered response never reads as complete coverage.
*/
interface TrendCell {
count: number;
import type {
VerdictCalibrationPathRow as WirePathRow,
VerdictCalibrationTrendWindow as WireTrendWindow,
} from './generated/wire-types.generated.js';
// Derived from the generated wire types so a backend rename of any read field goes
// tsc-red here; the deltas below are THIS tool's output contract, not the wire.
interface TrendCell extends Omit<WireTrendWindow, 'sessions'> {
/** Dropped in shaped output — the denominators live once in `trend`. */
sessions?: number;
sharePct: number;
}
export interface VerdictCalibrationPathRow {
verdictPath: string;
status: string;
count: number;
sharePct: number;
overriddenByAdmin: number;
overriddenByLateCompletion: number;
overriddenOther: number;
export interface VerdictCalibrationPathRow
extends Omit<WirePathRow, 'reEnrollRatePct' | 'lift' | 'window7' | 'baseline28'> {
/** Explicit null in shaped output (wire omits the key when withheld). */
reEnrollRatePct?: number | null;
/** Explicit null in shaped output (wire omits the key when withheld). */
lift?: number | null;
window7: TrendCell;
baseline28: TrendCell;
@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models.Backup
{
/// <summary>
/// Response of <c>GET global/backups</c>: every backupId in the critical-table-backups
/// container, newest first. Serialized with <see cref="BackupManifestJson.SerializerOptions"/>
/// (the backup surface's own options), not the ApiJsonOptions pipeline.
/// </summary>
public class ListBackupsResponse : IApiResponse
{
public IReadOnlyList<string> BackupIds { get; set; } = default!;
}
}
@@ -0,0 +1,48 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>
/// Response of <c>GET config/{tenantId}/feature-flags</c>: the member-readable subset of
/// the tenant configuration — UI display toggles, feature switches and the read-time
/// edition/entitlement surface. Adding a field here is a deliberate decision that the
/// field is non-sensitive (no webhook URLs, SAS tokens, allowlists, addresses).
/// </summary>
public class TenantFeatureFlagsResponse : IApiResponse
{
/// <summary>EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive Community enable) — field name kept for web compatibility.</summary>
public bool BootstrapTokenEnabled { get; set; }
/// <summary>Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail.</summary>
public bool DiagnosticsUploadConfigured { get; set; }
/// <summary>Drives the "Autopilot Device Validation disabled" dashboard banner.</summary>
public bool ValidateAutopilotDevice { get; set; }
/// <summary>Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids.</summary>
public bool AppHomingFunnelActive { get; set; }
public bool ShowScriptOutput { get; set; }
public bool EnableSoftwareInventoryAnalyzer { get; set; }
public bool EnableIntegrityBypassAnalyzer { get; set; }
/// <summary>EFFECTIVE gather-rule unrestricted mode (Pro edition + GA gate + tenant toggle).</summary>
public bool UnrestrictedMode { get; set; }
/// <summary>Resolved edition, lowercase ("community" / "pro").</summary>
public string Edition { get; set; } = string.Empty;
public bool IsTrial { get; set; }
/// <summary>Absent unless the tenant is on an active trial.</summary>
public DateTime? TrialExpiresUtc { get; set; }
public bool TrialAvailable { get; set; }
/// <summary>Whether a contact address is stored (boolean only — the address stays admin-gated).</summary>
public bool ContactEmailSet { get; set; }
public TenantFeatureEntitlements Entitlements { get; set; } = default!;
}
/// <summary>Read-time entitlement surface of the resolved edition.</summary>
public class TenantFeatureEntitlements
{
public int RetentionCapDays { get; set; }
/// <summary>Absent when the platform default applies.</summary>
public int? UserRateLimitPerMinute { get; set; }
public bool DelegatedAdminAllowed { get; set; }
public string McpUsagePlan { get; set; } = string.Empty;
}
}
@@ -0,0 +1,34 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>
/// Response of <c>GET diagnostics/paths</c>: what every diagnostics package collects
/// before a tenant's own entries — the built-in section catalog (compiled into the agent)
/// and the platform-wide global paths set by Global Admins. Member-readable by design.
/// </summary>
public class DiagnosticsPathsResponse : IApiResponse
{
public IReadOnlyList<DiagnosticsBuiltInSectionWire> BuiltIn { get; set; } = default!;
public IReadOnlyList<DiagnosticsLogPath> GlobalPaths { get; set; } = default!;
}
/// <summary>
/// Wire projection of one built-in diagnostics section. <c>Condition</c> travels as the
/// enum NAME ("Always" | "RealmJoinWatcher" | "DevicePreparation"), never the integer —
/// the web switches on the string.
/// </summary>
public class DiagnosticsBuiltInSectionWire
{
public string Id { get; set; } = string.Empty;
public string ZipFolder { get; set; } = string.Empty;
/// <summary>UNEXPANDED source folder (may contain %ProgramData% or the user-profile token).</summary>
public string SourceFolder { get; set; } = string.Empty;
public IReadOnlyList<string> Patterns { get; set; } = default!;
public bool IncludeSubfolders { get; set; }
public string Description { get; set; } = string.Empty;
public string Condition { get; set; } = string.Empty;
}
}
@@ -0,0 +1,37 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>Response of GET feedback/eligibility: whether the caller should be shown the feedback prompt.</summary>
public class FeedbackEligibilityResponse : IApiResponse
{
public bool Eligible { get; set; }
}
/// <summary>Response of GET feedback/all (Global Admin dashboard): every stored feedback entry.</summary>
public class FeedbackListResponse : IApiResponse
{
public IReadOnlyList<FeedbackEntryWire> Feedback { get; set; } = default!;
}
/// <summary>One feedback interaction as rendered on the Global Admin dashboard.</summary>
public class FeedbackEntryWire
{
public string? Type { get; set; }
public string? Upn { get; set; }
public string? TenantId { get; set; }
public string? DisplayName { get; set; }
/// <summary>Absent on dismissals.</summary>
public int? Rating { get; set; }
/// <summary>Absent on dismissals.</summary>
public string? Comment { get; set; }
public bool Dismissed { get; set; }
public bool Submitted { get; set; }
/// <summary>ISO-8601 round-trip string; absent when never stamped.</summary>
public string? InteractedAt { get; set; }
public string? HistoryRowKey { get; set; }
public string? DomainName { get; set; }
}
}
@@ -10,7 +10,7 @@ namespace AutopilotMonitor.Shared.Models
/// Presentation-only derivations (bar-chart maxima, weekday/month axis labels) stay on the
/// client; this payload carries the data, not the formatting.
/// </summary>
public sealed class FleetHealthMetrics
public sealed class FleetHealthMetrics : IApiResponse
{
public bool Success { get; set; } = true;
public int Days { get; set; }
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Response containing geographic performance metrics aggregated by location.
/// </summary>
public class GeographicMetricsResponse
public class GeographicMetricsResponse : IApiResponse
{
public bool Success { get; set; }
public List<LocationMetrics> Locations { get; set; } = new();
@@ -32,7 +32,7 @@ namespace AutopilotMonitor.Shared.Models
}
/// <summary>Response of <c>GET metrics/ime-pattern-health</c>.</summary>
public class ImePatternHealthResponse
public class ImePatternHealthResponse : IApiResponse
{
public string? BaselineVersion { get; set; }
public int MinBaselineSessions { get; set; }
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Agent performance / efficiency wire DTOs, moved verbatim from the Functions-local
// service files (PlatformMetricsService.cs / AgentEfficiencyMetricsService.cs) so the
// manifest exports them: property names and DECLARATION ORDER are the wire contract
// and must not change in a move-only refactor.
/// <summary>Response of <c>GET global/metrics/platform</c> (GetGlobalPlatformMetrics).</summary>
public class PlatformAgentMetricsResponse : IApiResponse
{
public List<SessionAgentMetric> Sessions { get; set; } = new();
public DeliveryLatencyMetrics? DeliveryLatency { get; set; }
public CrashRateMetrics? CrashRate { get; set; }
public DateTime ComputedAt { get; set; }
public int ComputeDurationMs { get; set; }
public bool FromCache { get; set; }
public int WindowDays { get; set; }
public int SessionLimit { get; set; }
/// <summary>
/// Sessions the scan actually covered in the window (before the has-snapshots filter
/// that shapes <see cref="Sessions"/>). Callers must compare THIS against
/// <see cref="SessionLimit"/> to decide whether the window was truncated —
/// <c>Sessions.Count</c> understates truncation on fleets where many sessions
/// emit no agent_metrics_snapshot.
/// </summary>
public int SessionsScanned { get; set; }
}
public class SessionAgentMetric
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string? DeviceName { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? StartedAt { get; set; }
public string? Status { get; set; }
public string? AgentVersion { get; set; }
public int SnapshotCount { get; set; }
public double TotalBytesUp { get; set; }
public double TotalBytesDown { get; set; }
public double TotalRequests { get; set; }
public double AvgCpu { get; set; }
public double MaxCpu { get; set; }
public double AvgWorkingSet { get; set; }
public double MaxWorkingSet { get; set; }
public double AvgPrivateBytes { get; set; }
public double AvgLatency { get; set; }
public double AvgSpoolDepth { get; set; }
public double MaxSpoolDepth { get; set; }
public double PeakSpoolDepth { get; set; }
public double MaxSpoolFileBytes { get; set; }
public double TotalEventsEmitted { get; set; }
public bool SpoolPressureDetected { get; set; }
}
public class DeliveryLatencyMetrics
{
public double P50Ms { get; set; }
public double P95Ms { get; set; }
public double P99Ms { get; set; }
public double AvgMs { get; set; }
public int SampleCount { get; set; }
public double ClockSkewPercent { get; set; }
}
public class CrashRateMetrics
{
public int TotalStarts { get; set; }
public int CleanExits { get; set; }
public int ExceptionCrashes { get; set; }
public int HardKills { get; set; }
public int RebootKills { get; set; }
public int FirstRuns { get; set; }
public double CrashRatePercent { get; set; }
public List<CrashExceptionSummary> TopExceptions { get; set; } = new();
}
public class CrashExceptionSummary
{
public string ExceptionType { get; set; } = string.Empty;
public int Count { get; set; }
}
/// <summary>Response of <c>GET global/metrics/agent-efficiency</c> (GetGlobalAgentEfficiency).</summary>
public class AgentEfficiencyMetricsResponse : IApiResponse
{
public int WindowDays { get; set; }
public int SessionLimit { get; set; }
/// <summary>Echo of the requested tenant filter; null = cross-tenant aggregate.</summary>
public string? TenantId { get; set; }
/// <summary>Sessions the scan covered — compare against <see cref="SessionLimit"/> for truncation.</summary>
public int SessionsScanned { get; set; }
public int SessionsWithSnapshots { get; set; }
public List<AgentVersionEfficiency> ByVersion { get; set; } = new();
public AgentVersionEfficiency? Overall { get; set; }
public DateTime ComputedAt { get; set; }
public int ComputeDurationMs { get; set; }
public bool FromCache { get; set; }
}
public class AgentVersionEfficiency
{
/// <summary>Null on the cross-version "overall" bucket (omitted on the wire).</summary>
public string? AgentVersion { get; set; }
public int SessionsScanned { get; set; }
public int SessionsWithSnapshots { get; set; }
public int SpoolPressureSessions { get; set; }
public PercentileStats? AvgCpuPercent { get; set; }
public PercentileStats? MaxCpuPercent { get; set; }
public PercentileStats? MaxWorkingSetMb { get; set; }
public PercentileStats? MaxPrivateBytesMb { get; set; }
public PercentileStats? MaxThreadCount { get; set; }
public PercentileStats? MaxHandleCount { get; set; }
public PercentileStats? MaxSpoolDepth { get; set; }
public PercentileStats? MaxSpoolFileBytes { get; set; }
public PercentileStats? ApiLatencyMs { get; set; }
public PercentileStats? ApiRequestCount { get; set; }
public CrashRateMetrics? CrashRate { get; set; }
public List<EfficiencyOffender>? TopOffenders { get; set; }
}
/// <summary>Distribution of a per-session statistic across a version bucket.</summary>
public class PercentileStats
{
public double P50 { get; set; }
public double P95 { get; set; }
public double Max { get; set; }
public double Avg { get; set; }
public int SampleCount { get; set; }
}
public class EfficiencyOffender
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string? DeviceName { get; set; }
public string Dimension { get; set; } = string.Empty;
public double Value { get; set; }
}
}
@@ -0,0 +1,68 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>
/// Response of <c>GET metrics/app</c> and <c>GET global/metrics/app</c>: per-app install
/// health over the requested window (slowest apps by average FINAL-attempt duration, top
/// failing apps) plus the fleet Delivery Optimization rollup.
/// </summary>
public class AppMetricsResponse : IApiResponse
{
public bool Success { get; set; }
public int TotalApps { get; set; }
public int TotalInstalls { get; set; }
public int TotalSkipped { get; set; }
public int TotalUnmeasured { get; set; }
/// <summary>Rows excluded from per-app groups because their name-keyed row merged distinct appIds.</summary>
public int TotalCollisionExcluded { get; set; }
public IReadOnlyList<AppMetricsAppGroup> SlowestApps { get; set; } = default!;
public IReadOnlyList<AppMetricsAppGroup> TopFailingApps { get; set; } = default!;
public AppMetricsDeliveryOptimization DeliveryOptimization { get; set; } = default!;
}
/// <summary>One app's aggregate across its install rows in the window.</summary>
public class AppMetricsAppGroup
{
public string AppName { get; set; } = string.Empty;
public int TotalInstalls { get; set; }
public int Succeeded { get; set; }
public int Skipped { get; set; }
public int Unmeasured { get; set; }
public int Failed { get; set; }
/// <summary>Failed / (failed + succeeded) as a percentage; skips never count as attempts.</summary>
public double FailureRate { get; set; }
/// <summary>Average measured FINAL-attempt duration (whole seconds); 0 with no measured installs.</summary>
public double AvgDurationSeconds { get; set; }
public int MaxDurationSeconds { get; set; }
public int MeasuredInstalls { get; set; }
public long AvgDownloadBytes { get; set; }
public long DoTotalBytesDownloaded { get; set; }
public long DoBytesFromPeers { get; set; }
public long DoBytesFromCacheServer { get; set; }
public long DoBytesFromHttp { get; set; }
public double PeerOffloadPercent { get; set; }
/// <summary>Top 3 failure codes by count, descending.</summary>
public IReadOnlyList<AppFailureCodeCount> TopFailureCodes { get; set; } = default!;
}
/// <summary>One failure code with its occurrence count.</summary>
public class AppFailureCodeCount
{
public string Code { get; set; } = string.Empty;
public int Count { get; set; }
}
/// <summary>Fleet-wide Delivery Optimization rollup across every install row in the window.</summary>
public class AppMetricsDeliveryOptimization
{
public long TotalBytesDownloaded { get; set; }
public long FromPeers { get; set; }
public long FromCacheServer { get; set; }
public long FromHttp { get; set; }
/// <summary>Share of bytes not pulled from the CDN (peers + Microsoft Connected Cache), 0-100 one decimal.</summary>
public double PeerOffloadPercent { get; set; }
}
}
@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order. App Dashboard endpoints (apps/list, apps/{app}/analytics,
// apps/{app}/sessions and their global variants) — lifted from the Functions-local
// anonymous builders in AppsAnalyticsHelper so the manifest exports them.
/// <summary>
/// Response of <c>GET apps/list</c> and <c>GET global/apps/list</c>. Legacy mode (no
/// pageSize) returns the full array with the paging keys absent; opt-in pagination adds
/// count/offset/pageSize and a nextLink while more pages remain.
/// </summary>
public class AppsListResponse : IApiResponse
{
public bool Success { get; set; }
public int TotalApps { get; set; }
public int TotalInstalls { get; set; }
/// <summary>Rows excluded because their name-keyed row merged distinct appIds.</summary>
public int CollisionExcluded { get; set; }
public int WindowDays { get; set; }
/// <summary>Rows on this page; absent in legacy full-array mode.</summary>
public int? Count { get; set; }
/// <summary>Absent in legacy full-array mode.</summary>
public int? Offset { get; set; }
/// <summary>Absent in legacy full-array mode.</summary>
public int? PageSize { get; set; }
public IReadOnlyList<AppsListItem> Apps { get; set; } = default!;
/// <summary>Next-page link; absent in legacy mode and on the last page.</summary>
public string? NextLink { get; set; }
}
/// <summary>One app row of the apps list (failed desc, then failure rate, then name).</summary>
public class AppsListItem
{
public string AppName { get; set; } = string.Empty;
public string AppType { get; set; } = string.Empty;
public int TotalInstalls { get; set; }
public int Succeeded { get; set; }
public int Skipped { get; set; }
public int Unmeasured { get; set; }
public int Failed { get; set; }
public double FailureRate { get; set; }
public double AvgDurationSeconds { get; set; }
public int MaxDurationSeconds { get; set; }
public long AvgDownloadBytes { get; set; }
/// <summary>"improving" | "worsening" | "stable".</summary>
public string Trend { get; set; } = string.Empty;
/// <summary>Failure-rate delta between window halves; absent when either half has under 5 finished installs.</summary>
public double? TrendDelta { get; set; }
public DateTime LastSeenAt { get; set; }
}
/// <summary>Response of <c>GET apps/{appName}/analytics</c> and its global variant.</summary>
public class AppAnalyticsResponse : IApiResponse
{
public bool Success { get; set; }
public string AppName { get; set; } = string.Empty;
public string AppType { get; set; } = string.Empty;
public int WindowDays { get; set; }
public int CollisionExcluded { get; set; }
/// <summary>"day" (windows up to 30 days) or "week".</summary>
public string Bucket { get; set; } = string.Empty;
public AppAnalyticsSummary Summary { get; set; } = default!;
public IReadOnlyList<AppAnalyticsTimeBucket> TimeSeries { get; set; } = default!;
public IReadOnlyList<AppVersionBreakdownItem> VersionBreakdown { get; set; } = default!;
public IReadOnlyList<AppInstallerPhaseCount> InstallerPhaseBreakdown { get; set; } = default!;
public IReadOnlyList<AppAnalyticsFailureCode> TopFailureCodes { get; set; } = default!;
/// <summary>Succeeded installs whose detection re-check reported NotDetected.</summary>
public int DetectionLiesCount { get; set; }
public IReadOnlyList<AppDeviceModelBreakdownItem> DeviceModelBreakdown { get; set; } = default!;
/// <summary>Active duration-regression episodes for this app (tracker rows).</summary>
public IReadOnlyList<AppVersionRegressionAlert> VersionRegressions { get; set; } = default!;
}
/// <summary>Headline aggregate of one app's analytics window.</summary>
public class AppAnalyticsSummary
{
public int TotalInstalls { get; set; }
public int Succeeded { get; set; }
public int Skipped { get; set; }
public int Unmeasured { get; set; }
public int Failed { get; set; }
public double FailureRate { get; set; }
public double AvgDurationSeconds { get; set; }
public int P95DurationSeconds { get; set; }
public long AvgDownloadBytes { get; set; }
/// <summary>"improving" | "worsening" | "stable".</summary>
public string Trend { get; set; } = string.Empty;
/// <summary>Absent when either window half has under 5 finished installs.</summary>
public double? TrendDelta { get; set; }
/// <summary>Share of installs with AttemptNumber &gt; 1 (0-1, three decimals).</summary>
public double FlakinessScore { get; set; }
}
/// <summary>One day/week bucket of the analytics time series.</summary>
public class AppAnalyticsTimeBucket
{
public DateTime BucketStart { get; set; }
public int Installs { get; set; }
public int Succeeded { get; set; }
public int Failed { get; set; }
public double FailureRate { get; set; }
public double AvgDurationSeconds { get; set; }
}
/// <summary>Per-app-version aggregate (installs descending).</summary>
public class AppVersionBreakdownItem
{
public string AppVersion { get; set; } = string.Empty;
public int Installs { get; set; }
public int Failed { get; set; }
public double FailureRate { get; set; }
public int MeasuredInstalls { get; set; }
public int MedianDurationSeconds { get; set; }
public int P95DurationSeconds { get; set; }
}
/// <summary>Failed installs per installer phase (descending).</summary>
public class AppInstallerPhaseCount
{
public string Phase { get; set; } = string.Empty;
public int Failed { get; set; }
}
/// <summary>One of the top 5 failure codes of the analytics window.</summary>
public class AppAnalyticsFailureCode
{
public string Code { get; set; } = string.Empty;
/// <summary>First observed exit code; absent when none of the rows carried one.</summary>
public int? ExitCode { get; set; }
public int Count { get; set; }
public string SampleMessage { get; set; } = string.Empty;
}
/// <summary>Per device-model failure aggregate (lift vs the app's baseline rate, descending).</summary>
public class AppDeviceModelBreakdownItem
{
public string Manufacturer { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public int Installs { get; set; }
public int Failed { get; set; }
public double FailureRate { get; set; }
public double LiftVsBaseline { get; set; }
}
/// <summary>Response of <c>GET apps/{appName}/sessions</c> and its global variant (offset-paged).</summary>
public class AppSessionsResponse : IApiResponse
{
public bool Success { get; set; }
public int Total { get; set; }
public int Offset { get; set; }
public int Limit { get; set; }
public IReadOnlyList<AppSessionItem> Items { get; set; } = default!;
}
/// <summary>One install row of the app-sessions drilldown (failed first, then in-progress, newest first).</summary>
public class AppSessionItem
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string DeviceName { get; set; } = string.Empty;
public string Manufacturer { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public string AppVersion { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string InstallerPhase { get; set; } = string.Empty;
public string FailureCode { get; set; } = string.Empty;
/// <summary>Absent when the install carried no exit code.</summary>
public int? ExitCode { get; set; }
public int AttemptNumber { get; set; }
public DateTime StartedAt { get; set; }
public int DurationSeconds { get; set; }
/// <summary>2+ = the IME processed this app in multiple passes (device-ESP evaluation + real install).</summary>
public int InstallPassCount { get; set; }
}
}
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order. Envelopes of the F1/F2 insights fleet endpoints,
// lifted from the Functions-local anonymous builders so the manifest exports them.
/// <summary>
/// Response of <c>GET metrics/time-attribution</c> and <c>GET global/metrics/time-attribution</c>:
/// the rolling 30-day range statistics per enrollment class plus the daily rows for the
/// per-day trend. The range window is FIXED at the sweep's 30 days.
/// </summary>
public class TimeAttributionMetricsResponse : IApiResponse
{
public bool Success { get; set; }
public int WindowDays { get; set; }
/// <summary>Range statistics per enrollment class (never mixed), class-name ordinal order.</summary>
public IReadOnlyList<TimeAttributionDailyAggregate> Classes { get; set; } = default!;
/// <summary>Daily rows of the window, date ordinal order.</summary>
public IReadOnlyList<TimeAttributionDailyAggregate> Daily { get; set; } = default!;
}
/// <summary>
/// Response of <c>GET metrics/device-journeys</c> and <c>GET global/metrics/device-journeys</c>:
/// daily First-Time-Right rows of the window plus their sums, the merged attempt histogram,
/// and the repeat-devices violator list (absent on the cross-tenant aggregate).
/// </summary>
public class DeviceJourneyMetricsResponse : IApiResponse
{
public bool Success { get; set; }
public int WindowDays { get; set; }
public DeviceJourneyWindowTotals Totals { get; set; } = default!;
/// <summary>Daily rows of the window, date ordinal order.</summary>
public IReadOnlyList<DeviceJourneyDailyAggregate> Daily { get; set; } = default!;
/// <summary>Devices whose current journey took at least 2 attempts; absent on the cross-tenant aggregate (no per-device drill there).</summary>
public IReadOnlyList<DeviceJourneyRepeatDevice>? RepeatDevices { get; set; }
}
/// <summary>Window totals of the device-journey response (additive sums over the daily rows).</summary>
public class DeviceJourneyWindowTotals
{
public int CompletedJourneys { get; set; }
public int FirstTimeRight { get; set; }
/// <summary>Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1).</summary>
public double? FtrRatePct { get; set; }
public int ExcludedSessions { get; set; }
public List<DeviceJourneyAttemptBucket> AttemptHistogram { get; set; } = new();
}
/// <summary>One repeat-device violator row (current journey took at least 2 attempts, newest terminal session in the window).</summary>
public class DeviceJourneyRepeatDevice
{
public string SerialNumber { get; set; } = string.Empty;
public string Manufacturer { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public int Attempts { get; set; }
public int JourneyCount { get; set; }
public string LastStatus { get; set; } = string.Empty;
public string LastSessionId { get; set; } = string.Empty;
public DateTime LastStartedAt { get; set; }
/// <summary>Failure reason of the newest failed attempt; empty when unavailable (fail-soft point-read).</summary>
public string LastFailureReason { get; set; } = string.Empty;
}
}
@@ -0,0 +1,67 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order (STJ serializes in declaration order; the MCP hands
// raw response JSON to an LLM, so key order is part of the contract).
/// <summary>
/// Response of <c>GET metrics/rule-stats</c> and <c>GET global/metrics/rule-stats</c>:
/// per-rule firing aggregates over the requested window, the active rule-frequency
/// regression episodes (tenant scope only — empty on the cross-tenant aggregate), and
/// window totals.
/// </summary>
public class RuleStatsResponse : IApiResponse
{
public IReadOnlyList<RuleStatsRuleAggregate> Rules { get; set; } = default!;
public IReadOnlyList<RuleRegressionAlert> Regressions { get; set; } = default!;
public RuleStatsSummary Summary { get; set; } = default!;
}
/// <summary>One rule's aggregate across all dates in the window (fires descending).</summary>
public class RuleStatsRuleAggregate
{
public string RuleId { get; set; } = string.Empty;
public string RuleType { get; set; } = string.Empty;
public string RuleTitle { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Severity { get; set; } = string.Empty;
public int FireCount { get; set; }
public int EvaluationCount { get; set; }
public int SessionsEvaluated { get; set; }
/// <summary>Fires per evaluation as a percentage (one decimal); 0 with no evaluations.</summary>
public double HitRate { get; set; }
/// <summary>Average confidence score across fires (one decimal); 0 with no fires.</summary>
public double AvgConfidenceScore { get; set; }
/// <summary>Per-day trend rows, oldest first (one row per stored date).</summary>
public IReadOnlyList<RuleTrendPoint> Trend { get; set; } = default!;
}
/// <summary>One day of a rule's trend ("yyyy-MM-dd").</summary>
public class RuleTrendPoint
{
public string Date { get; set; } = string.Empty;
public int FireCount { get; set; }
public int EvaluationCount { get; set; }
}
/// <summary>Window totals of a rule-stats response.</summary>
public class RuleStatsSummary
{
public int TotalEvaluations { get; set; }
public int TotalFires { get; set; }
public double OverallHitRate { get; set; }
/// <summary>Rule id with the most fires; absent when the window holds no rules.</summary>
public string? TopRuleByFireCount { get; set; }
/// <summary>Distinct rule count — set on the global route only (the key is absent on the tenant route, preserving its historical shape).</summary>
public int? UniqueRules { get; set; }
public RuleStatsPeriod Period { get; set; } = default!;
}
/// <summary>Echo of the effective date window ("yyyy-MM-dd").</summary>
public class RuleStatsPeriod
{
public string Start { get; set; } = string.Empty;
public string End { get; set; } = string.Empty;
}
}
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Shared.Models.Metrics
/// <summary>
/// SLA metrics response for a given tenant and time window.
/// </summary>
public class SlaMetricsResponse
public class SlaMetricsResponse : IApiResponse
{
// Target echo (so the dashboard knows the configured targets)
public decimal? TargetSuccessRate { get; set; }
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Platform usage metrics response
/// </summary>
public class PlatformUsageMetrics
public class PlatformUsageMetrics : IApiResponse
{
/// <summary>
/// Session metrics
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order. Moved verbatim from the Functions-local
// VerdictCalibrationResponse builder (GetVerdictCalibrationFunction.cs) so the shared
// manifest exports the calibration matrix as wire types.
/// <summary>
/// Response of <c>GET global/metrics/verdict-calibration</c>: per verdict path, how many
/// sessions it produced in the window, its share, overrides attributed to the prior path,
/// the 7-day re-enrollment proxy, a 7d-vs-28d trend, and the active drift alerts.
/// Operator-only classifier diagnostics.
/// </summary>
public class VerdictCalibrationResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>Partition echo: a tenant GUID, or "global" for the cross-tenant aggregate.</summary>
public string TenantId { get; set; } = string.Empty;
public int WindowDays { get; set; }
/// <summary>Inclusive window start ("yyyy-MM-dd").</summary>
public string WindowStart { get; set; } = string.Empty;
/// <summary>Inclusive window end ("yyyy-MM-dd", today).</summary>
public string WindowEnd { get; set; } = string.Empty;
/// <summary>Newest aggregate compute time in the window; absent when the window holds no rows.</summary>
public DateTime? ComputedAt { get; set; }
/// <summary>Distinct aggregation algorithm versions contributing to the window, ascending.</summary>
public int[] Versions { get; set; } = default!;
public VerdictCalibrationTotals Totals { get; set; } = default!;
public VerdictCalibrationTrendMeta Trend { get; set; } = default!;
/// <summary>Rows ordered by count descending, then path/status ordinal.</summary>
public IReadOnlyList<VerdictCalibrationPathRow> Paths { get; set; } = default!;
/// <summary>Active drift episodes, newest first.</summary>
public IReadOnlyList<VerdictCalibrationAlert> Alerts { get; set; } = default!;
}
/// <summary>Window totals of the calibration matrix.</summary>
public class VerdictCalibrationTotals
{
public int Sessions { get; set; }
public int Terminal { get; set; }
public int Derived { get; set; }
/// <summary>Aggregate days that contributed to the window.</summary>
public int Days { get; set; }
}
/// <summary>Trend denominators shared by every row (single source, never per-row copies).</summary>
public class VerdictCalibrationTrendMeta
{
public int WindowDays { get; set; }
public int BaselineDays { get; set; }
public int WindowSessions { get; set; }
public int BaselineSessions { get; set; }
}
/// <summary>One verdict path × status row of the calibration matrix.</summary>
public class VerdictCalibrationPathRow
{
public string VerdictPath { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public int Count { get; set; }
public double SharePct { get; set; }
public int DerivedCount { get; set; }
public int Eligible7d { get; set; }
public int ReEnrolled7d { get; set; }
/// <summary>Null below the minimum eligible count — never a rate on a handful of sessions.</summary>
public double? ReEnrollRatePct { get; set; }
public int OverriddenByAdmin { get; set; }
public int OverriddenByLateCompletion { get; set; }
public int OverriddenOther { get; set; }
public VerdictCalibrationTrendWindow Window7 { get; set; } = new();
public VerdictCalibrationTrendWindow Baseline28 { get; set; } = new();
/// <summary>Window share ÷ baseline share; null when the baseline share is 0 (a new path has no finite lift — never invented).</summary>
public double? Lift { get; set; }
}
/// <summary>One trend window of a path row.</summary>
public class VerdictCalibrationTrendWindow
{
public int Count { get; set; }
public int Sessions { get; set; }
public double SharePct { get; set; }
}
}
@@ -0,0 +1,25 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>
/// 202/200 response body for the offboarding endpoint. Fields point the caller at the
/// History row so subsequent reporting / status polling can resolve back to the audit
/// trail. <see cref="EarliestProcessingAt"/> drives the "data deletion starts in mm ss"
/// countdown in the Web UI's drain-barrier state.
/// </summary>
public class OffboardResponse : IApiResponse
{
public string TenantId { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string HistoryPartitionKey { get; set; } = string.Empty;
public string HistoryRowKey { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
/// <summary>UTC timestamp before which the worker MUST NOT start Phase 2. Drives the
/// cache-drain-barrier countdown UI. Absent on the idempotent-Completed/Failed branches.</summary>
public DateTime? EarliestProcessingAt { get; set; }
}
}
@@ -671,6 +671,20 @@ namespace AutopilotMonitor.Shared.Models
public string? MsiSourceUrl { get; set; }
}
/// <summary>
/// Redacted IME version row served to non-global callers of <c>GET metrics/ime-versions</c>:
/// the platform-wide rollout facts without the first-seen tenant/session identifiers or
/// the archive columns (see <c>GetImeVersionHistoryFunction.BuildResponsePayload</c>).
/// </summary>
[WireContract]
public class ImeVersionHistoryLeanEntry
{
public string Version { get; set; } = default!;
public DateTime FirstSeenAt { get; set; }
public DateTime LastSeenAt { get; set; }
public int SessionCount { get; set; }
}
/// <summary>
/// Result of recording one IME version sighting (<c>RecordImeVersionAsync</c>): whether
/// the version is new to the fleet, plus the archive columns the ingest path needs to
@@ -0,0 +1,43 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>
/// Body of POST global/sessions/{id}/restore for every outcome (success AND reject —
/// the outcome/status mapping decides the HTTP code, the shape stays one).
/// </summary>
public class SessionRestoreResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>SessionRestoreOutcome name (e.g. "Restored", "DryRunOk", "RejectManifestNotFound").</summary>
public string Outcome { get; set; } = string.Empty;
/// <summary>"full" | "partial" | "dryRun"; absent on early rejects.</summary>
public string? Mode { get; set; }
/// <summary>Operator-readable reason; absent on clean successes.</summary>
public string? Message { get; set; }
/// <summary>Reject diagnostics; absent otherwise.</summary>
public string? CurrentState { get; set; }
/// <summary>Reject diagnostics; absent otherwise.</summary>
public string? PendingManifestId { get; set; }
public Dictionary<string, int> RowsRestoredByTable { get; set; } = default!;
public Dictionary<string, int> RowsSkippedByTable { get; set; } = default!;
public Dictionary<string, int> WouldRestoreByTable { get; set; } = default!;
public int InventoryReIncrements { get; set; }
public long DurationMs { get; set; }
}
/// <summary>
/// Success body (202 Accepted) of the V2 cascade-delete enqueue — the non-success arms
/// stay anonymous error bodies by design (one shape: success=false + message).
/// </summary>
public class SessionDeletionQueuedResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>Always "queued".</summary>
public string Status { get; set; } = string.Empty;
public string? ManifestId { get; set; }
public string Message { get; set; } = string.Empty;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff