diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PlatformStatsMonotonicTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformStatsMonotonicTests.cs
new file mode 100644
index 00000000..26a245f5
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformStatsMonotonicTests.cs
@@ -0,0 +1,129 @@
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Pins the "since release" semantics of the platform-stats recompute: every cumulative
+/// counter is a monotonic high-water-mark. The tables the recompute scans are pruned by
+/// retention, so a raw recompute only sees the retention window — persisting that verbatim
+/// regressed the public landing-page figures after every cleanup.
+///
+public class PlatformStatsMonotonicTests
+{
+ private static readonly DateTime Now = new(2026, 07, 11, 12, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void Cumulative_counters_never_regress_below_persisted_values()
+ {
+ // Retention pruned old sessions: the recompute sees far less than the persisted
+ // counters accumulated. Every cumulative figure must keep its high-water-mark.
+ var existing = new PlatformStats
+ {
+ TotalEnrollments = 50_000,
+ SuccessfulEnrollments = 40_000,
+ TotalEventsProcessed = 9_000_000,
+ TotalUsers = 800,
+ TotalTenants = 120,
+ UniqueDeviceModels = 450,
+ IssuesDetected = 6_500,
+ TotalSignedUpTenants = 150
+ };
+
+ var stats = MaintenanceService.BuildMonotonicPlatformStats(
+ recomputedEnrollments: 7_000,
+ recomputedSuccessful: 6_000,
+ recomputedEvents: 1_200_000,
+ recomputedUsers: 90,
+ recomputedActiveTenants: 80,
+ recomputedUniqueModels: 200,
+ signedUpTenants: 155,
+ existing: existing,
+ nowUtc: Now);
+
+ Assert.Equal(50_000, stats.TotalEnrollments);
+ Assert.Equal(40_000, stats.SuccessfulEnrollments);
+ Assert.Equal(9_000_000, stats.TotalEventsProcessed);
+ Assert.Equal(800, stats.TotalUsers);
+ Assert.Equal(120, stats.TotalTenants);
+ Assert.Equal(450, stats.UniqueDeviceModels);
+ Assert.Equal(6_500, stats.IssuesDetected);
+ }
+
+ [Fact]
+ public void Recompute_raises_counters_when_live_data_exceeds_persisted_values()
+ {
+ // Self-heal: lost increments (fire-and-forget failures, pre-feature history) are
+ // recovered as long as the sessions are still within retention.
+ var existing = new PlatformStats
+ {
+ TotalEnrollments = 1_000,
+ SuccessfulEnrollments = 700,
+ TotalEventsProcessed = 100_000,
+ TotalUsers = 50,
+ TotalTenants = 10,
+ UniqueDeviceModels = 30
+ };
+
+ var stats = MaintenanceService.BuildMonotonicPlatformStats(
+ recomputedEnrollments: 1_500,
+ recomputedSuccessful: 1_100,
+ recomputedEvents: 250_000,
+ recomputedUsers: 80,
+ recomputedActiveTenants: 14,
+ recomputedUniqueModels: 42,
+ signedUpTenants: 20,
+ existing: existing,
+ nowUtc: Now);
+
+ Assert.Equal(1_500, stats.TotalEnrollments);
+ Assert.Equal(1_100, stats.SuccessfulEnrollments);
+ Assert.Equal(250_000, stats.TotalEventsProcessed);
+ Assert.Equal(80, stats.TotalUsers);
+ Assert.Equal(14, stats.TotalTenants);
+ Assert.Equal(42, stats.UniqueDeviceModels);
+ }
+
+ [Fact]
+ public void SignedUpTenants_is_current_state_and_may_drop()
+ {
+ // Deliberate exception: TenantConfiguration is not retention-pruned, so its count is
+ // authoritative — a drop reflects real offboarding, not data loss.
+ var existing = new PlatformStats { TotalSignedUpTenants = 150 };
+
+ var stats = MaintenanceService.BuildMonotonicPlatformStats(
+ recomputedEnrollments: 0, recomputedSuccessful: 0, recomputedEvents: 0,
+ recomputedUsers: 0, recomputedActiveTenants: 0, recomputedUniqueModels: 0,
+ signedUpTenants: 140,
+ existing: existing,
+ nowUtc: Now);
+
+ Assert.Equal(140, stats.TotalSignedUpTenants);
+ }
+
+ [Fact]
+ public void First_run_without_persisted_row_uses_recomputed_values()
+ {
+ var stats = MaintenanceService.BuildMonotonicPlatformStats(
+ recomputedEnrollments: 10,
+ recomputedSuccessful: 8,
+ recomputedEvents: 500,
+ recomputedUsers: 3,
+ recomputedActiveTenants: 2,
+ recomputedUniqueModels: 4,
+ signedUpTenants: 5,
+ existing: null,
+ nowUtc: Now);
+
+ Assert.Equal(10, stats.TotalEnrollments);
+ Assert.Equal(8, stats.SuccessfulEnrollments);
+ Assert.Equal(500, stats.TotalEventsProcessed);
+ Assert.Equal(3, stats.TotalUsers);
+ Assert.Equal(2, stats.TotalTenants);
+ Assert.Equal(4, stats.UniqueDeviceModels);
+ Assert.Equal(0, stats.IssuesDetected);
+ Assert.Equal(5, stats.TotalSignedUpTenants);
+ Assert.Equal(Now, stats.LastFullCompute);
+ Assert.Equal(Now, stats.LastUpdated);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs
index 05c02b90..b3a92113 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs
@@ -381,31 +381,24 @@ namespace AutopilotMonitor.Functions.Services
var existingStats = await _metricsRepo.GetPlatformStatsAsync();
- // "Users Seen" is a cumulative, public-facing high-water-mark. UserActivity is pruned to
- // 90 days, so the recomputed `totalUsers` only reflects recent loginers — clamp it to the
- // previously-persisted value so the cumulative figure can never regress after a cleanup.
- var cumulativeUsers = Math.Max(totalUsers, existingStats?.TotalUsers ?? 0);
-
- var stats = new PlatformStats
- {
- TotalEnrollments = totalEnrollments,
- TotalUsers = cumulativeUsers,
- TotalTenants = activeTenants,
- TotalSignedUpTenants = allConfigs.Count,
- UniqueDeviceModels = uniqueModels.Count,
- TotalEventsProcessed = totalEvents,
- SuccessfulEnrollments = successfulEnrollments,
- IssuesDetected = existingStats?.IssuesDetected ?? 0,
- LastFullCompute = DateTime.UtcNow,
- LastUpdated = DateTime.UtcNow
- };
+ var stats = BuildMonotonicPlatformStats(
+ recomputedEnrollments: totalEnrollments,
+ recomputedSuccessful: successfulEnrollments,
+ recomputedEvents: totalEvents,
+ recomputedUsers: totalUsers,
+ recomputedActiveTenants: activeTenants,
+ recomputedUniqueModels: uniqueModels.Count,
+ signedUpTenants: allConfigs.Count,
+ existing: existingStats,
+ nowUtc: DateTime.UtcNow);
await _metricsRepo.SavePlatformStatsAsync(stats);
await TryPublishPlatformStatsJsonAsync(stats);
sw.Stop();
_logger.LogInformation($"Platform stats recomputed in {sw.ElapsedMilliseconds}ms: " +
- $"{totalEnrollments} enrollments, {cumulativeUsers} users (cumulative), {tenantIds.Count} tenants, {uniqueModels.Count} models");
+ $"{stats.TotalEnrollments} enrollments, {stats.TotalUsers} users, {tenantIds.Count} tenants, " +
+ $"{stats.UniqueDeviceModels} models (all cumulative high-water-marks)");
}
catch (Exception ex)
{
@@ -413,6 +406,43 @@ namespace AutopilotMonitor.Functions.Services
}
}
+ ///
+ /// Merges a fresh platform-stats recompute with the previously persisted row. Platform
+ /// stats are "since release" counters (public landing page): the tables the recompute
+ /// scans are pruned by session/user-activity retention, so a raw recompute can only see
+ /// the retention window and would regress the figures after every cleanup. Every
+ /// cumulative counter is therefore a monotonic high-water-mark — the recompute may raise
+ /// it (self-heal for lost increments), never lower it. IssuesDetected is increment-only
+ /// (no recompute source) and is carried over verbatim. TotalSignedUpTenants is the one
+ /// deliberate exception: the TenantConfiguration table is not retention-pruned, so its
+ /// count is authoritative current state and a drop reflects real offboarding, not data loss.
+ ///
+ internal static PlatformStats BuildMonotonicPlatformStats(
+ long recomputedEnrollments,
+ long recomputedSuccessful,
+ long recomputedEvents,
+ long recomputedUsers,
+ long recomputedActiveTenants,
+ long recomputedUniqueModels,
+ long signedUpTenants,
+ PlatformStats? existing,
+ DateTime nowUtc)
+ {
+ return new PlatformStats
+ {
+ TotalEnrollments = Math.Max(recomputedEnrollments, existing?.TotalEnrollments ?? 0),
+ SuccessfulEnrollments = Math.Max(recomputedSuccessful, existing?.SuccessfulEnrollments ?? 0),
+ TotalEventsProcessed = Math.Max(recomputedEvents, existing?.TotalEventsProcessed ?? 0),
+ TotalUsers = Math.Max(recomputedUsers, existing?.TotalUsers ?? 0),
+ TotalTenants = Math.Max(recomputedActiveTenants, existing?.TotalTenants ?? 0),
+ UniqueDeviceModels = Math.Max(recomputedUniqueModels, existing?.UniqueDeviceModels ?? 0),
+ TotalSignedUpTenants = signedUpTenants,
+ IssuesDetected = existing?.IssuesDetected ?? 0,
+ LastFullCompute = nowUtc,
+ LastUpdated = nowUtc
+ };
+ }
+
///
/// Publishes versioned platform stats JSON + alias manifest to Blob Storage.
/// This must never fail maintenance execution.
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/PlatformStats.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/PlatformStats.cs
index d8fb6f87..472c69ea 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/PlatformStats.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/PlatformStats.cs
@@ -5,7 +5,11 @@ namespace AutopilotMonitor.Shared.Models
///
/// Pre-computed platform-wide statistics for the public landing page.
/// Stored as a single row (PartitionKey: "global", RowKey: "current").
- /// Recomputed during daily maintenance; incremented during registration/login.
+ /// Incremented during registration/ingest/login; the daily maintenance recompute treats
+ /// every cumulative counter as a monotonic "since release" high-water-mark (raise-only —
+ /// the scanned tables are retention-pruned, so a raw recompute would regress the figures).
+ /// Only TotalSignedUpTenants is current-state (its source table is not retention-pruned).
+ /// See MaintenanceService.BuildMonotonicPlatformStats.
///
public class PlatformStats
{