From 445ce4e75b6a17ddd6b244803383a169e52f841b Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 22 Aug 2026 21:00:53 +0200 Subject: [PATCH] feat(backend): 30-day retention grace period on Pro downgrade Retention was the only enforcement point that destroyed state on a Pro -> Community downgrade: the 12-hourly sweep clamps to the Community cap at read time and would immediately start hard-deleting the 90-365-day band (trial expiry identical, silently). New policy: for 30 days after losing Pro (FeatureEntitlementCatalog.RetentionDowngradeGraceDays) the Pro cap keeps applying. Anchor model: ProDowngradedUtc (backend-only field, patch-denied + revert-protected) stamped/cleared by the plan endpoint's new pure ApplyPlanChanges core on EFFECTIVE-edition transitions (covers explicitly ended trials; a planTier downgrade under an active trial deliberately does not stamp); trial expiry anchors read-time via TrialExpiresUtc. days<=0 (infinite) stays untouched. Ops visibility: TenantPlanDowngraded (PATCH handler, new OpsEventService dep) plus TenantRetentionGraceExpiring/Ended from the daily TrialExpirySweep with the same stateless window mechanics, emitted only when stored retention exceeds the Community cap; all dual-registered in the web OPS_EVENT_TYPES catalog. Fanout now uses its _utcNow seam instead of inline DateTime.UtcNow. OKF doc backend/plan-downgrade-retention-grace.md. Co-Authored-By: Claude Fable 5 --- .../backend/plan-downgrade-retention-grace.md | 93 +++++++++++ docs/index.md | 1 + docs/log.md | 4 + .../PlanManagementTransitionTests.cs | 145 ++++++++++++++++++ .../SessionRetentionFanoutServiceTests.cs | 48 +++++- .../TenantConfigTableSerializationTests.cs | 7 +- .../TenantEntitlementServiceTests.cs | 93 ++++++++++- .../TrialExpirySweepFunctionTests.cs | 87 ++++++++++- .../TableStorage/TableConfigRepository.cs | 2 + .../Config/PlanManagementFunction.cs | 98 +++++++++--- .../Maintenance/TrialExpirySweepFunction.cs | 116 ++++++++++---- .../Security/FeatureEntitlementCatalog.cs | 10 ++ .../Deletion/SessionRetentionFanoutService.cs | 7 +- .../Services/OpsEventService.cs | 54 +++++++ .../Services/TenantConfigPatchService.cs | 2 + .../Services/TenantEntitlementService.cs | 35 ++++- .../Models/Config/TenantConfiguration.cs | 11 ++ .../admin/components/OpsAlertRulesSection.tsx | 9 ++ .../__tests__/sectionFieldMap.test.ts | 1 + .../utils/shared-manifests.generated.ts | 1 + .../utils/shared-manifests.json | 1 + 21 files changed, 761 insertions(+), 64 deletions(-) create mode 100644 docs/backend/plan-downgrade-retention-grace.md create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs diff --git a/docs/backend/plan-downgrade-retention-grace.md b/docs/backend/plan-downgrade-retention-grace.md new file mode 100644 index 00000000..07fdefda --- /dev/null +++ b/docs/backend/plan-downgrade-retention-grace.md @@ -0,0 +1,93 @@ +--- +type: concept +title: Plan Downgrade Policy & the Retention Grace Period +description: What happens when a tenant loses Pro (explicit downgrade or trial expiry) — every entitlement gates read-time and reversibly, except retention, which gets a 30-day grace before the Community cap starts hard-deleting; the anchor model, the ops events, and the deliberate non-grace of everything else. +resource: backend +tags: [plan, edition, downgrade, retention, grace-period, trial, entitlements] +timestamp: 2026-08-22 +--- + +# Schema + +## The downgrade model: gate, don't destroy + +Edition is resolved at READ time (`FeatureEntitlementCatalog.ResolveEdition`): Pro ⇔ +permanent tier `pro`/legacy `enterprise` OR an active trial. A plan mutation is a single +field write (`PATCH config/{tenantId}/plan`, GA-only) — there is no downgrade event +handler, no cascade. On Pro → Community every entitlement simply gates on the next read +(≤ ~5 min config cache): + +* **Unrestricted Mode** — effective value flips false; the GA on-request flag and the + tenant toggle survive untouched (inert). Re-upgrade restores it verbatim. +* **Delegated (MSP) admin** — scope suppressed at the home-tenant gate + (`DelegatedAdminService.ApplyHomeTenantGateAsync`); grant rows / tenant groups / + assignments are never touched. Resurrects on re-upgrade. +* **OOBE bootstrap** — endpoints 403/404 unless the additive GA per-tenant flag + (`BootstrapTokenEnabled`) is set; `BootstrapSessions` rows are kept. +* **Rate limits / MCP quota** — pure computed-at-read floors/plan names. + +**Retention is the one exception**: the retention sweep (12-hourly +`SessionDeletionMaintenance` → `SessionRetentionFanoutService`) hard-deletes sessions +older than `GetEffectiveRetentionDays` = `min(stored DataRetentionDays, edition cap)`. +Without protection, a downgrade (or trial expiry) with stored retention 365 would start +irreversibly cascade-deleting the 90–365-day band on the next tick. + +## The retention grace period (30 days) + +`FeatureEntitlementCatalog.RetentionDowngradeGraceDays = 30`. For 30 days after losing +Pro, `GetEffectiveRetentionDays` keeps clamping to the **Pro** cap (365); only after the +grace does the Community cap (90) bite. The stored `DataRetentionDays` is never rewritten. +`days <= 0` (GA-only "infinite") short-circuits before any cap — those tenants are never +swept, grace or not. + +**Anchor** — "when was Pro lost" (`TenantEntitlementService.GetRetentionGraceEndUtc`, +returns null while effectively Pro; grace end = latest anchor + 30d): + +* **Explicit downgrade**: `TenantConfiguration.ProDowngradedUtc` (backend-only field, no + agent ConfigVersion impact), maintained exclusively by the plan endpoint's pure mutation + core `PlanManagementFunction.ApplyPlanChanges`. Stamped when the EFFECTIVE edition + (before vs. after, same `nowUtc`) transitions Pro → Community — this covers both a + planTier downgrade and a GA explicitly ending a trial (`trialExpiresUtc: null`, where + the trial timestamp disappears and could not anchor). Cleared on any transition back to + effectively Pro (PATCH pro, trial grant, self-service trial start), so a re-upgrade also + resets the grace clock. +* **Trial expiry**: no write happens — the stored `TrialExpiresUtc` itself is the anchor + (read-time, like edition resolution). A planTier downgrade under a still-active trial + deliberately does NOT stamp: the edition is still Pro, and the later trial expiry + anchors the grace then. + +The field is on the patch-endpoint `BaseDeniedFields` and `RevertProtectedFields` lists +(a config revert must not time-travel the anchor) and roundtrips the table-serialization +contract (Store+Map + roundtrip tests). Legacy rows read null → no anchor → no grace, +which is correct: no tenant had been downgraded before the feature existed. + +## Ops visibility (dual-registered event types) + +* `TenantPlanDowngraded` (Warning) — emitted by the PATCH handler on an effective + Pro→Community transition; carries `retentionGraceEndsUtc` + stored retention days. The + response body also returns `retentionGraceEndsUtc`. +* `TenantRetentionGraceExpiring` / `TenantRetentionGraceEnded` (Warning) — emitted by the + daily `TrialExpirySweepFunction` (03:30 UTC) with the same stateless window mechanics as + the trial events (≤3-day heads-up re-fired daily; ended once via the 24h look-back), and + ONLY when data is actually at risk: stored `DataRetentionDays` above the Community cap + (0 = infinite ⇒ never). Re-upgraded tenants go silent automatically (grace resolves null). + +All three are registered in the web `OPS_EVENT_TYPES` catalog (OpsAlertRulesSection.tsx) +so Telegram alert rules can route them. + +## Deliberate non-goals + +* No grace for Unrestricted/MSP/bootstrap/limits — reversible gates need none. +* The retention WRITE cap (`TenantConfigValidation`) does not honor the grace: during the + grace a tenant admin cannot SET a value above the Community cap. The grace protects + existing data; new writes follow the new plan. +* The feature-flags endpoint keeps publishing the plain edition cap + (`retentionCapDays`) — it feeds the settings input maximum, which is the write cap. + +# Citations + +* `src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs` — grace + effective retention +* `src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs` — `ApplyPlanChanges` anchor lifecycle +* `src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs` — grace event windows +* `src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs` — enforcement point +* Tests: `TenantEntitlementServiceTests`, `PlanManagementTransitionTests`, `TrialExpirySweepFunctionTests`, `SessionRetentionFanoutServiceTests`, `TenantConfigTableSerializationTests` diff --git a/docs/index.md b/docs/index.md index 9d3f77f1..ead4ca41 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,6 +53,7 @@ https://docs.autopilotmonitor.com (separate repository). * [Attempt-Scoped App Install Durations](backend/app-install-duration-attempt.md) - AppInstallSummary.DurationSeconds = status-defining (last) install attempt via the LastAttemptStartedAt max-fold anchor instead of the first-observation→last-terminal span that billed multi-pass IME gaps to trivial apps; InstallPassCount, the weaker-terminal guard, the radar's cutover gate, and time attribution's union-of-attempt-segments occupancy. * [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract. * [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces. +* [Plan Downgrade Policy & the Retention Grace Period](backend/plan-downgrade-retention-grace.md) - What happens when a tenant loses Pro: every entitlement gates read-time and reversibly ("gate, don't destroy"), except retention — a 30-day grace keeps the Pro cap before the Community cap starts hard-deleting; the ProDowngradedUtc/trial-expiry anchor model, the TenantPlanDowngraded/RetentionGrace* ops events, and the deliberate non-grace of everything else. * [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other. * [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else. * [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade. diff --git a/docs/log.md b/docs/log.md index 7c5e3d31..4518a4fb 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-22 + +* **New**: `backend/plan-downgrade-retention-grace.md` — plan downgrade policy: every Pro entitlement gates read-time and reversibly on downgrade ("gate, don't destroy"), except retention, which now gets a 30-day grace (`FeatureEntitlementCatalog.RetentionDowngradeGraceDays`) before the Community cap starts hard-deleting the 90–365-day band. Anchor model: `ProDowngradedUtc` stamped/cleared by the plan endpoint's pure `ApplyPlanChanges` core on EFFECTIVE-edition transitions (covers explicitly ended trials; planTier downgrade under an active trial deliberately doesn't stamp), trial expiry anchors read-time via `TrialExpiresUtc`. New Warning ops events `TenantPlanDowngraded` (PATCH handler) and `TenantRetentionGraceExpiring`/`TenantRetentionGraceEnded` (TrialExpirySweep window mechanics, only when stored retention exceeds the Community cap). Field is patch-denied + revert-protected; retention WRITE cap deliberately ignores the grace. + ## 2026-08-21 * **New**: `agent/do-group-id-auto-set.md` — optional DO peering-group seed (`EnableDoGroupIdAutoSet`, default off, ConfigVersion 40): `DoGroupIdService` derives a deterministic GUID from the default gateway's IPv4 + SendARP MAC in RealmJoin's exact byte layout (cross-product peering) and writes `DOGroupId` in the 64-bit GPO Policies store with readback verification. Conservative by design: no random-GUID fallback (skip on missing gateway/MAC), `DOGroupIdSource`/MDM-managed values/foreign GPO values always win (skip reasons in the `do_group_id_auto_set` event), own stale values are recognized via the `written:` StartupEventGate fingerprint and updated after a gateway change. Gateway heuristic extracted into the shared `NetworkInterfaceLocator` (dedup of NetworkChangeDetector + DeviceInfoCollector copies). diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs new file mode 100644 index 00000000..fcd10f7d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs @@ -0,0 +1,145 @@ +using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for — the pure mutation core +/// of the PATCH plan endpoint. Pins the retention-grace anchor lifecycle: an EFFECTIVE +/// Pro→Community transition stamps , any +/// effectively-Pro outcome clears it, and a planTier downgrade under an active trial does +/// NOT stamp (the edition is still Pro — the later trial expiry is the anchor then). +/// +public class PlanManagementTransitionTests +{ + private static readonly DateTime Now = new(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc); + private const string Caller = "ga@operator.example"; + + private static (TenantEdition Before, TenantEdition After, Dictionary Changes) Apply( + TenantConfiguration config, string? planTier = null, bool trialProvided = false, DateTime? trialExpiresUtc = null) + { + var changes = new Dictionary(); + var (before, after) = PlanManagementFunction.ApplyPlanChanges( + config, planTier, trialProvided, trialExpiresUtc, Caller, Now, changes); + return (before, after, changes); + } + + [Fact] + public void Downgrade_ProToCommunity_StampsAnchorAndRecordsChange() + { + var config = new TenantConfiguration { TenantId = "t1", PlanTier = "pro" }; + + var (before, after, changes) = Apply(config, planTier: "community"); + + Assert.Equal(TenantEdition.Pro, before); + Assert.Equal(TenantEdition.Community, after); + Assert.Equal(Now, config.ProDowngradedUtc); + Assert.True(changes.ContainsKey("ProDowngradedUtc")); + Assert.True(changes.ContainsKey("PlanTier")); + } + + [Fact] + public void Upgrade_CommunityToPro_ClearsAnchor() + { + var config = new TenantConfiguration + { + TenantId = "t1", + PlanTier = "community", + ProDowngradedUtc = Now.AddDays(-10) + }; + + var (_, after, changes) = Apply(config, planTier: "pro"); + + Assert.Equal(TenantEdition.Pro, after); + Assert.Null(config.ProDowngradedUtc); + Assert.True(changes.ContainsKey("ProDowngradedUtc")); + } + + [Fact] + public void Downgrade_WithActiveTrial_DoesNotStamp_EditionStaysPro() + { + // planTier drops but the trial keeps the EFFECTIVE edition Pro: no anchor now — + // the trial's own expiry timestamp anchors the grace later, read-time. + var config = new TenantConfiguration + { + TenantId = "t1", + PlanTier = "pro", + TrialExpiresUtc = Now.AddDays(5) + }; + + var (before, after, changes) = Apply(config, planTier: "community"); + + Assert.Equal(TenantEdition.Pro, before); + Assert.Equal(TenantEdition.Pro, after); + Assert.Null(config.ProDowngradedUtc); + Assert.False(changes.ContainsKey("ProDowngradedUtc")); + } + + [Fact] + public void EndingTrialExplicitly_StampsAnchor() + { + // GA sets trialExpiresUtc: null on a trial-Pro tenant — TrialExpiresUtc is gone, so + // WITHOUT the explicit stamp the grace would have no anchor at all. + var config = new TenantConfiguration + { + TenantId = "t1", + PlanTier = "free", + TrialExpiresUtc = Now.AddDays(5) + }; + + var (before, after, _) = Apply(config, trialProvided: true, trialExpiresUtc: null); + + Assert.Equal(TenantEdition.Pro, before); + Assert.Equal(TenantEdition.Community, after); + Assert.Equal(Now, config.ProDowngradedUtc); + Assert.Null(config.TrialExpiresUtc); + } + + [Fact] + public void GrantingTrial_ToDowngradedTenant_ClearsAnchor() + { + var config = new TenantConfiguration + { + TenantId = "t1", + PlanTier = "community", + ProDowngradedUtc = Now.AddDays(-10) + }; + + var (_, after, _) = Apply(config, trialProvided: true, trialExpiresUtc: Now.AddDays(30)); + + Assert.Equal(TenantEdition.Pro, after); + Assert.Null(config.ProDowngradedUtc); + Assert.Equal(Caller, config.TrialGrantedBy); + } + + [Fact] + public void NoOpPatch_SameTier_RecordsNothing() + { + var config = new TenantConfiguration { TenantId = "t1", PlanTier = "community" }; + + var (before, after, changes) = Apply(config, planTier: "community"); + + Assert.Equal(TenantEdition.Community, before); + Assert.Equal(TenantEdition.Community, after); + Assert.Empty(changes); + Assert.Null(config.ProDowngradedUtc); + } + + [Fact] + public void RepeatedDowngradeAfterReUpgrade_RestampsWithNewTimestamp() + { + var config = new TenantConfiguration { TenantId = "t1", PlanTier = "pro" }; + + Apply(config, planTier: "community"); + Assert.Equal(Now, config.ProDowngradedUtc); + + Apply(config, planTier: "pro"); + Assert.Null(config.ProDowngradedUtc); + + var changes = new Dictionary(); + var later = Now.AddDays(40); + PlanManagementFunction.ApplyPlanChanges(config, "community", false, null, Caller, later, changes); + Assert.Equal(later, config.ProDowngradedUtc); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs index cd3d0cf8..0743bc62 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs @@ -331,8 +331,9 @@ public class SessionRetentionFanoutServiceTests var baseTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var deadline = baseTime.AddMinutes(50); var clockCalls = 0; - // Call sequence: tenant-A boundary (1), session s0 (2), session s1 (3), session s2 (4 → past deadline). - var harness = new Harness(utcNow: () => ++clockCalls >= 4 ? deadline.AddMinutes(1) : baseTime); + // Call sequence: tenant-A boundary (1), retention resolution (2), session s0 (3), + // session s1 (4), session s2 (5 → past deadline). + var harness = new Harness(utcNow: () => ++clockCalls >= 5 ? deadline.AddMinutes(1) : baseTime); var sessions = new List(); for (int i = 0; i < 4; i++) sessions.Add(Summary(TenantA, $"s{i}")); @@ -388,6 +389,38 @@ public class SessionRetentionFanoutServiceTests }; } + // =========================================== Retention downgrade grace ==== + + [Fact] + public async Task Tenant_InRetentionGrace_KeepsProCutoff() + { + // Downgraded 10 days ago, stored retention 365: the grace keeps the Pro cap, so the + // cutoff must be now-365d — NOT now-90d (which would enqueue the 90–365d band). + var now = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc); + var harness = new Harness(utcNow: () => now); + harness.WithTenant("t1", retentionDays: 365, sessions: Array.Empty(), + planTier: "community", proDowngradedUtc: now.AddDays(-10)); + + await harness.RunAsync(); + + harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync( + "t1", now.AddDays(-365), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Tenant_AfterRetentionGrace_CommunityCutoffApplies() + { + var now = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc); + var harness = new Harness(utcNow: () => now); + harness.WithTenant("t1", retentionDays: 365, sessions: Array.Empty(), + planTier: "community", proDowngradedUtc: now.AddDays(-31)); + + await harness.RunAsync(); + + harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync( + "t1", now.AddDays(-90), It.IsAny(), It.IsAny()), Times.Once); + } + // ============================================================ Harness ==== private sealed class Harness @@ -450,11 +483,18 @@ public class SessionRetentionFanoutServiceTests return result; } - public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free") + public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free", + DateTime? proDowngradedUtc = null) { _tenantIds.Add(tenantId); TenantConfig.Setup(t => t.GetConfigurationAsync(tenantId)) - .ReturnsAsync(new TenantConfiguration { TenantId = tenantId, DataRetentionDays = retentionDays, PlanTier = planTier }); + .ReturnsAsync(new TenantConfiguration + { + TenantId = tenantId, + DataRetentionDays = retentionDays, + PlanTier = planTier, + ProDowngradedUtc = proDowngradedUtc, + }); MaintenanceRepo.Setup(m => m.GetSessionsOlderThanAsync(tenantId, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List(WithTenantId(tenantId, sessions))); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs index 4fd4416b..7ad747cb 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs @@ -20,6 +20,7 @@ public class TenantConfigTableSerializationTests { var expires = new DateTime(2026, 8, 6, 10, 30, 0, DateTimeKind.Utc); var started = new DateTime(2026, 7, 7, 10, 30, 0, DateTimeKind.Utc); + var downgraded = new DateTime(2026, 8, 10, 8, 0, 0, DateTimeKind.Utc); var config = new TenantConfiguration { TenantId = TenantId, @@ -29,7 +30,8 @@ public class TenantConfigTableSerializationTests TrialExpiresUtc = expires, TrialStartedUtc = started, TrialConsumed = true, - TrialGrantedBy = "alice@contoso.com" + TrialGrantedBy = "alice@contoso.com", + ProDowngradedUtc = downgraded }; var entity = TableConfigRepository.ConvertToTenantTableEntity(config); @@ -40,6 +42,7 @@ public class TenantConfigTableSerializationTests Assert.Equal(started, mapped.TrialStartedUtc); Assert.True(mapped.TrialConsumed); Assert.Equal("alice@contoso.com", mapped.TrialGrantedBy); + Assert.Equal(downgraded, mapped.ProDowngradedUtc); } [Fact] @@ -59,6 +62,7 @@ public class TenantConfigTableSerializationTests Assert.Null(mapped.TrialStartedUtc); Assert.False(mapped.TrialConsumed); Assert.Null(mapped.TrialGrantedBy); + Assert.Null(mapped.ProDowngradedUtc); } [Fact] @@ -77,6 +81,7 @@ public class TenantConfigTableSerializationTests Assert.Null(mapped.TrialExpiresUtc); Assert.False(mapped.TrialConsumed); Assert.Null(mapped.TrialGrantedBy); + Assert.Null(mapped.ProDowngradedUtc); // pre-grace row: no anchor, no grace } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs index f926d196..b4108f81 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs @@ -194,8 +194,10 @@ public class TenantEntitlementServiceTests } [Fact] - public void GetEffectiveRetentionDays_TrialExpiry_DegradesCapFrom365To90() + public void GetEffectiveRetentionDays_TrialExpiry_ProCapHoldsThroughGrace_ThenDegradesTo90() { + // Trial expiry is a downgrade: the Pro cap keeps applying for the 30-day retention + // grace (anchor = TrialExpiresUtc, no write involved), THEN the Community cap bites. var config = new TenantConfiguration { TenantId = TenantId, @@ -204,6 +206,93 @@ public class TenantEntitlementServiceTests TrialExpiresUtc = Now.AddDays(1) }; Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now)); - Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2))); + // 1 day after expiry: inside the grace — still the Pro cap. + Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2))); + // 31 days after expiry: grace over — Community cap enforced. + Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(32))); + } + + // ── Retention downgrade grace (GetRetentionGraceEndUtc + cap interplay) ────── + + [Fact] + public void GetRetentionGraceEndUtc_ProTenant_ReturnsNull() + { + var config = new TenantConfiguration + { + TenantId = TenantId, + PlanTier = "pro", + ProDowngradedUtc = Now.AddDays(-5) // stale leftover must not matter while Pro + }; + Assert.Null(TenantEntitlementService.GetRetentionGraceEndUtc(config, Now)); + } + + [Fact] + public void GetRetentionGraceEndUtc_NeverLostPro_ReturnsNull() + { + var config = new TenantConfiguration { TenantId = TenantId, PlanTier = "free" }; + Assert.Null(TenantEntitlementService.GetRetentionGraceEndUtc(config, Now)); + } + + [Fact] + public void GetRetentionGraceEndUtc_ExplicitDowngrade_AnchorPlusGraceDays() + { + var downgraded = Now.AddDays(-10); + var config = new TenantConfiguration + { + TenantId = TenantId, + PlanTier = "community", + ProDowngradedUtc = downgraded + }; + Assert.Equal( + downgraded.AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays), + TenantEntitlementService.GetRetentionGraceEndUtc(config, Now)); + } + + [Fact] + public void GetRetentionGraceEndUtc_LatestAnchorWins() + { + // Downgrade, re-granted trial, trial expired again: the LATER trial expiry anchors. + var config = new TenantConfiguration + { + TenantId = TenantId, + PlanTier = "community", + ProDowngradedUtc = Now.AddDays(-20), + TrialExpiresUtc = Now.AddDays(-3) + }; + Assert.Equal( + Now.AddDays(-3).AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays), + TenantEntitlementService.GetRetentionGraceEndUtc(config, Now)); + } + + [Fact] + public void GetEffectiveRetentionDays_ExplicitDowngrade_ProCapDuringGrace_CommunityCapAfter() + { + var config = new TenantConfiguration + { + TenantId = TenantId, + PlanTier = "community", + DataRetentionDays = 365, + ProDowngradedUtc = Now + }; + Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(29))); + Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(31))); + } + + [Fact] + public void GetEffectiveRetentionDays_GraceDoesNotRaiseAnythingElse() + { + // 0 (infinite) passes through untouched, and a stored value below the Community cap + // is unaffected — the grace only swaps which CAP clamps. + var zero = new TenantConfiguration + { + TenantId = TenantId, PlanTier = "community", DataRetentionDays = 0, ProDowngradedUtc = Now + }; + Assert.Equal(0, TenantEntitlementService.GetEffectiveRetentionDays(zero, Now.AddDays(5))); + + var below = new TenantConfiguration + { + TenantId = TenantId, PlanTier = "community", DataRetentionDays = 60, ProDowngradedUtc = Now + }; + Assert.Equal(60, TenantEntitlementService.GetEffectiveRetentionDays(below, Now.AddDays(5))); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs index 7ac0c1d3..2a88544b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs @@ -20,7 +20,8 @@ public class TrialExpirySweepFunctionTests private static readonly DateTime Now = new(2026, 7, 7, 3, 30, 0, DateTimeKind.Utc); private static TenantConfiguration Tenant( - string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com") => new() + string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com", + int retentionDays = 90, DateTime? proDowngradedUtc = null) => new() { TenantId = id, DomainName = domain, @@ -28,6 +29,8 @@ public class TrialExpirySweepFunctionTests PlanTier = planTier, TrialExpiresUtc = trialExpiresUtc, TrialConsumed = trialExpiresUtc.HasValue, + DataRetentionDays = retentionDays, + ProDowngradedUtc = proDowngradedUtc, }; private static (TrialExpirySweepFunction Sut, List Events) Build(params TenantConfiguration[] configs) @@ -150,6 +153,88 @@ public class TrialExpirySweepFunctionTests Assert.Equal(2, events.Count); } + // ── Retention downgrade grace events ───────────────────────────────────────── + + [Fact] + public async Task GraceEndingSoon_WithDataAtRisk_EmitsGraceExpiring() + { + // Downgraded 28 days ago with stored retention 365: grace ends in 2 days. + var (sut, events) = Build(Tenant("t1", null, planTier: "community", + retentionDays: 365, proDowngradedUtc: Now.AddDays(-28))); + + var result = await sut.RunCoreAsync(CancellationToken.None); + + Assert.Equal(1, result.GraceExpiringEmitted); + var e = Assert.Single(events); + Assert.Equal("TenantRetentionGraceExpiring", e.EventType); + Assert.Equal(OpsEventSeverity.Warning, e.Severity); + Assert.Contains("2 day", e.Message); + } + + [Fact] + public async Task GraceEndedWithinLookBack_EmitsGraceEnded_OlderStaysSilent() + { + var (sut, events) = Build( + // Grace ended 12h ago → emit once. + Tenant("just-ended", null, planTier: "community", + retentionDays: 365, proDowngradedUtc: Now.AddDays(-30).AddHours(-12)), + // Grace ended 3 days ago → reported by a previous run, silent. + Tenant("long-ended", null, planTier: "community", + retentionDays: 365, proDowngradedUtc: Now.AddDays(-33))); + + var result = await sut.RunCoreAsync(CancellationToken.None); + + Assert.Equal(1, result.GraceEndedEmitted); + var e = Assert.Single(events); + Assert.Equal("TenantRetentionGraceEnded", e.EventType); + Assert.Equal("just-ended", e.TenantId); + } + + [Fact] + public async Task Grace_NoDataAtRisk_StaysSilent() + { + var (sut, events) = Build( + // Stored retention at the Community cap — nothing to lose. + Tenant("at-cap", null, planTier: "community", + retentionDays: 90, proDowngradedUtc: Now.AddDays(-29)), + // 0 = infinite escape hatch — the fanout never deletes, no warning needed. + Tenant("infinite", null, planTier: "community", + retentionDays: 0, proDowngradedUtc: Now.AddDays(-29))); + + var result = await sut.RunCoreAsync(CancellationToken.None); + + Assert.Equal(0, result.GraceExpiringEmitted); + Assert.Equal(0, result.GraceEndedEmitted); + Assert.Empty(events); + } + + [Fact] + public async Task Grace_ReUpgradedTenant_StaysSilent() + { + // Back on Pro: GetRetentionGraceEndUtc returns null even with a stale anchor. + var (sut, events) = Build(Tenant("t1", null, planTier: "pro", + retentionDays: 365, proDowngradedUtc: Now.AddDays(-29))); + + var result = await sut.RunCoreAsync(CancellationToken.None); + + Assert.Equal(0, result.GraceExpiringEmitted); + Assert.Empty(events); + } + + [Fact] + public async Task ExpiredTrial_WithDataAtRisk_GetsBothTrialExpiredAndLaterGraceEvents() + { + // The trial-expiry anchor drives the grace window too: expired 2h ago fires + // TenantTrialExpired now; 28 days later the same tenant enters the grace heads-up. + var (sut, events) = Build(Tenant("t1", Now.AddHours(-2), retentionDays: 365)); + + var result = await sut.RunCoreAsync(CancellationToken.None); + + Assert.Equal(1, result.ExpiredEmitted); + Assert.Equal(0, result.GraceExpiringEmitted); // grace end is ~30d out — beyond heads-up + Assert.Single(events); + } + [Fact] public async Task ConfigLoadFailure_ReturnsEmptyResult_NeverThrows() { diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index da7585d0..7be2cecc 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -671,6 +671,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "TrialStartedUtc", config.TrialStartedUtc }, { "TrialConsumed", config.TrialConsumed }, { "TrialGrantedBy", config.TrialGrantedBy }, + { "ProDowngradedUtc", config.ProDowngradedUtc }, // SLA targets { "SlaTargetSuccessRate", config.SlaTargetSuccessRate.HasValue ? (double)config.SlaTargetSuccessRate.Value : (double?)null }, { "SlaTargetMaxDurationMinutes", config.SlaTargetMaxDurationMinutes }, @@ -783,6 +784,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"), TrialConsumed = entity.GetBoolean("TrialConsumed") ?? false, TrialGrantedBy = entity.GetString("TrialGrantedBy"), + ProDowngradedUtc = entity.GetDateTime("ProDowngradedUtc"), // SLA targets SlaTargetSuccessRate = entity.GetDouble("SlaTargetSuccessRate") != null ? (decimal)entity.GetDouble("SlaTargetSuccessRate")! : null, SlaTargetMaxDurationMinutes = entity.GetInt32("SlaTargetMaxDurationMinutes"), diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs index 4c9a2b63..a8088233 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs @@ -27,14 +27,16 @@ namespace AutopilotMonitor.Functions.Functions.Config private readonly TenantConfigurationService _configService; private readonly AdminConfigurationService _adminConfigService; private readonly IMaintenanceRepository _maintenanceRepo; + private readonly OpsEventService _opsEvents; private readonly TimeProvider _time; public PlanManagementFunction( ILogger logger, TenantConfigurationService configService, AdminConfigurationService adminConfigService, - IMaintenanceRepository maintenanceRepo) - : this(logger, configService, adminConfigService, maintenanceRepo, TimeProvider.System) + IMaintenanceRepository maintenanceRepo, + OpsEventService opsEvents) + : this(logger, configService, adminConfigService, maintenanceRepo, opsEvents, TimeProvider.System) { } @@ -44,12 +46,14 @@ namespace AutopilotMonitor.Functions.Functions.Config TenantConfigurationService configService, AdminConfigurationService adminConfigService, IMaintenanceRepository maintenanceRepo, + OpsEventService opsEvents, TimeProvider time) { _logger = logger; _configService = configService; _adminConfigService = adminConfigService; _maintenanceRepo = maintenanceRepo; + _opsEvents = opsEvents; _time = time; } @@ -139,22 +143,8 @@ namespace AutopilotMonitor.Functions.Functions.Config var nowUtc = _time.GetUtcNow().UtcDateTime; var changes = new Dictionary(); - if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal)) - { - changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}"; - config.PlanTier = newPlanTier; - } - - if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc) - { - changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}"; - config.TrialExpiresUtc = newTrialExpiresUtc; - if (newTrialExpiresUtc.HasValue) - { - config.TrialStartedUtc ??= nowUtc; - config.TrialGrantedBy = caller; - } - } + var (editionBefore, editionAfter) = + ApplyPlanChanges(config, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes); if (changes.Count > 0) { @@ -171,9 +161,17 @@ namespace AutopilotMonitor.Functions.Functions.Config requestCtx.TargetTenantId, caller, changes); - } - var effectiveEdition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc); + if (editionBefore == TenantEdition.Pro && editionAfter == TenantEdition.Community) + { + await _opsEvents.RecordTenantPlanDowngradedAsync( + requestCtx.TargetTenantId, + config.DomainName, + caller, + TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc), + config.DataRetentionDays); + } + } var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new @@ -182,7 +180,8 @@ namespace AutopilotMonitor.Functions.Functions.Config planTier = config.PlanTier, trialExpiresUtc = config.TrialExpiresUtc, trialConsumed = config.TrialConsumed, - effectiveEdition = effectiveEdition.ToString().ToLowerInvariant() + effectiveEdition = editionAfter.ToString().ToLowerInvariant(), + retentionGraceEndsUtc = TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc) }); return response; } @@ -195,6 +194,61 @@ namespace AutopilotMonitor.Functions.Functions.Config } } + /// + /// Pure mutation core of the PATCH plan endpoint — no I/O, unit-testable. Applies the + /// plan/trial changes to , records human-readable entries in + /// , and returns the EFFECTIVE edition before/after (same + /// for both, so only the mutation itself can flip it). + /// + /// Maintains the retention downgrade grace anchor: an effective Pro→Community + /// transition stamps (covers both a + /// planTier downgrade and an explicitly ended trial); any state that is effectively Pro + /// afterwards clears the anchor, so a re-upgrade also resets the grace clock. + /// + internal static (TenantEdition Before, TenantEdition After) ApplyPlanChanges( + TenantConfiguration config, + string? newPlanTier, + bool trialProvided, + DateTime? newTrialExpiresUtc, + string caller, + DateTime nowUtc, + Dictionary changes) + { + var before = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc); + + if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal)) + { + changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}"; + config.PlanTier = newPlanTier; + } + + if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc) + { + changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}"; + config.TrialExpiresUtc = newTrialExpiresUtc; + if (newTrialExpiresUtc.HasValue) + { + config.TrialStartedUtc ??= nowUtc; + config.TrialGrantedBy = caller; + } + } + + var after = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc); + + if (before == TenantEdition.Pro && after == TenantEdition.Community) + { + changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> {FormatUtc(nowUtc)}"; + config.ProDowngradedUtc = nowUtc; + } + else if (after == TenantEdition.Pro && config.ProDowngradedUtc.HasValue) + { + changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> (none)"; + config.ProDowngradedUtc = null; + } + + return (before, after); + } + /// /// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service /// 30-day Pro trial, exactly once per tenant. 409 when the trial was already @@ -262,6 +316,8 @@ namespace AutopilotMonitor.Functions.Functions.Config config.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays); config.TrialConsumed = true; config.TrialGrantedBy = caller; + // Effectively Pro again — a leftover retention grace anchor is obsolete. + config.ProDowngradedUtc = null; config.UpdatedBy = caller; // Fail-loud: throws when the write did not persist (cache invalidated in finally). diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs index cee6f3a2..98a697e5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs @@ -10,15 +10,21 @@ using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Maintenance; /// -/// Daily informational sweep over tenant Pro trials. NOT load-bearing — trial expiry -/// degrades a tenant to Community at READ time (FeatureEntitlementCatalog.ResolveEdition); -/// this timer only surfaces the transitions as ops events so the operator has visibility: +/// Daily informational sweep over the tenant plan lifecycle. NOT load-bearing — trial expiry +/// degrades a tenant to Community at READ time (FeatureEntitlementCatalog.ResolveEdition), +/// and the retention grace is likewise resolved read-time by the retention fanout; this timer +/// only surfaces the transitions as ops events so the operator has visibility: /// /// TenantTrialExpired (Warning) — the trial ended within the last sweep window /// (24h look-back matching the daily cadence). /// TenantTrialExpiring (Info) — the trial ends within the next /// days (re-emitted each daily run until expiry — /// acceptable for an Info-tier heads-up, no dedupe state to maintain). +/// TenantRetentionGraceExpiring / TenantRetentionGraceEnded (Warning) — +/// same window mechanics for the retention downgrade grace +/// (TenantEntitlementService.GetRetentionGraceEndUtc). Emitted only when data is +/// actually at risk: stored retention above the Community cap (0 = infinite ⇒ never +/// at risk, the fanout skips those tenants). /// /// Tenants whose stored PlanTier is already a permanent Pro tier ("pro"/legacy "enterprise") are skipped — /// their trial timestamps are inert leftovers and expiry changes nothing. @@ -90,45 +96,93 @@ public class TrialExpirySweepFunction { ct.ThrowIfCancellationRequested(); - if (config.TrialExpiresUtc is not DateTime expiry) - continue; - - // Permanent Pro: the trial timestamps are inert leftovers — expiry changes nothing. - if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier)) - continue; - - result.TrialsSeen++; - - if (expiry <= now) - { - if (expiry > now - ExpiredLookBack) - { - await _opsEvents.RecordTenantTrialExpiredAsync(config.TenantId, config.DomainName, expiry) - .ConfigureAwait(false); - result.ExpiredEmitted++; - } - // Older expiries were reported by a previous run — stay silent. - } - else if (expiry <= now + ExpiringHeadsUp) - { - var daysLeft = Math.Max(1, (int)Math.Ceiling((expiry - now).TotalDays)); - await _opsEvents.RecordTenantTrialExpiringAsync(config.TenantId, config.DomainName, expiry, daysLeft) - .ConfigureAwait(false); - result.ExpiringEmitted++; - } + await SweepTrialAsync(config, now, result).ConfigureAwait(false); + await SweepRetentionGraceAsync(config, now, result).ConfigureAwait(false); } _logger.LogInformation( - "TrialExpirySweep completed: trialsSeen={TrialsSeen} expiredEmitted={ExpiredEmitted} expiringEmitted={ExpiringEmitted}", - result.TrialsSeen, result.ExpiredEmitted, result.ExpiringEmitted); + "TrialExpirySweep completed: trialsSeen={TrialsSeen} expiredEmitted={ExpiredEmitted} expiringEmitted={ExpiringEmitted} " + + "graceExpiringEmitted={GraceExpiringEmitted} graceEndedEmitted={GraceEndedEmitted}", + result.TrialsSeen, result.ExpiredEmitted, result.ExpiringEmitted, + result.GraceExpiringEmitted, result.GraceEndedEmitted); return result; } + private async Task SweepTrialAsync( + AutopilotMonitor.Shared.Models.TenantConfiguration config, DateTime now, SweepResult result) + { + if (config.TrialExpiresUtc is not DateTime expiry) + return; + + // Permanent Pro: the trial timestamps are inert leftovers — expiry changes nothing. + if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier)) + return; + + result.TrialsSeen++; + + if (expiry <= now) + { + if (expiry > now - ExpiredLookBack) + { + await _opsEvents.RecordTenantTrialExpiredAsync(config.TenantId, config.DomainName, expiry) + .ConfigureAwait(false); + result.ExpiredEmitted++; + } + // Older expiries were reported by a previous run — stay silent. + } + else if (expiry <= now + ExpiringHeadsUp) + { + var daysLeft = Math.Max(1, (int)Math.Ceiling((expiry - now).TotalDays)); + await _opsEvents.RecordTenantTrialExpiringAsync(config.TenantId, config.DomainName, expiry, daysLeft) + .ConfigureAwait(false); + result.ExpiringEmitted++; + } + } + + /// + /// Retention downgrade grace visibility: warn shortly before and once after the grace ends, + /// but only when data is actually at risk — stored retention above the Community cap. + /// (0 = the GA-only infinite escape hatch: the fanout skips those tenants, nothing is ever + /// deleted, so the comparison excludes it naturally.) GetRetentionGraceEndUtc returns null + /// for effectively-Pro tenants, so re-upgraded tenants go silent automatically. + /// + private async Task SweepRetentionGraceAsync( + AutopilotMonitor.Shared.Models.TenantConfiguration config, DateTime now, SweepResult result) + { + var communityCap = FeatureEntitlementCatalog.Get(TenantEdition.Community).RetentionCapDays; + if (config.DataRetentionDays <= communityCap) + return; + + if (TenantEntitlementService.GetRetentionGraceEndUtc(config, now) is not DateTime graceEnd) + return; + + if (graceEnd <= now) + { + if (graceEnd > now - ExpiredLookBack) + { + await _opsEvents.RecordTenantRetentionGraceEndedAsync( + config.TenantId, config.DomainName, graceEnd, config.DataRetentionDays) + .ConfigureAwait(false); + result.GraceEndedEmitted++; + } + } + else if (graceEnd <= now + ExpiringHeadsUp) + { + var daysLeft = Math.Max(1, (int)Math.Ceiling((graceEnd - now).TotalDays)); + await _opsEvents.RecordTenantRetentionGraceExpiringAsync( + config.TenantId, config.DomainName, graceEnd, daysLeft, config.DataRetentionDays) + .ConfigureAwait(false); + result.GraceExpiringEmitted++; + } + } + /// Summary counters returned from so tests can assert outcomes. internal sealed class SweepResult { public int TrialsSeen { get; set; } public int ExpiredEmitted { get; set; } public int ExpiringEmitted { get; set; } + public int GraceExpiringEmitted { get; set; } + public int GraceEndedEmitted { get; set; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs index 9953cd24..b4e38158 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs @@ -95,6 +95,16 @@ namespace AutopilotMonitor.Functions.Security /// public const string LegacyEnterpriseTierName = "enterprise"; + /// + /// Retention downgrade grace period: for this many days after a tenant loses Pro + /// (explicit downgrade or trial expiry) the retention sweep keeps enforcing the PRO + /// cap, so the downgrade does not immediately hard-delete data older than the + /// Community cap. Retention is the only entitlement with a grace — everything else + /// gates read-time immediately because it is reversible without data loss. + /// Resolved via . + /// + public const int RetentionDowngradeGraceDays = 30; + private static readonly EditionEntitlements Community = new() { Edition = TenantEdition.Community, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs index 13d2af2e..c49b4d4e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs @@ -162,16 +162,17 @@ namespace AutopilotMonitor.Functions.Services.Deletion // trial expired, or whose stored value predates the cap, is enforced here even though // the stored DataRetentionDays is left untouched. days <= 0 stays the GA-only // "infinite" escape hatch (skipped below, never clamped). + var nowUtc = _utcNow(); var storedDays = config?.DataRetentionDays ?? 90; var retentionDays = config == null ? 90 - : TenantEntitlementService.GetEffectiveRetentionDays(config, DateTime.UtcNow); + : TenantEntitlementService.GetEffectiveRetentionDays(config, nowUtc); if (retentionDays > 0 && retentionDays < storedDays) { _logger.LogWarning( "Tenant {TenantId}: DataRetentionDays={Stored} exceeds the {Edition}-edition cap — enforcing {Effective} days", tenantId, storedDays, - TenantEntitlementService.ResolveEdition(config!, DateTime.UtcNow), retentionDays); + TenantEntitlementService.ResolveEdition(config!, nowUtc), retentionDays); } if (retentionDays <= 0) @@ -180,7 +181,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion return; } - var cutoffUtc = DateTime.UtcNow.AddDays(-retentionDays); + var cutoffUtc = nowUtc.AddDays(-retentionDays); // Server-bounded read: fetch one more than the per-run dispatch cap. The loop below // only ever advances MaxEnqueuesPerTenantPerRun sessions, so loading the whole backlog diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs index 96a0debe..c31d25d6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -527,6 +527,60 @@ namespace AutopilotMonitor.Functions.Services tenantId, "System.TrialSweep", new { domainName, trialExpiredUtc }); } + // ── Plan downgrade / retention grace (informational — enforcement is read-time) ── + // All three types are dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES + // (memory feedback_ops_event_types_dual_register). Downgraded is dispatched by + // PlanManagementFunction, the grace events by TrialExpirySweepFunction. + + /// + /// A GA plan mutation dropped the tenant's EFFECTIVE edition Pro → Community. The + /// retention downgrade grace period starts now; data older than the Community cap is + /// deleted only after it ends. Warning-tier: the business moment (e.g. non-payment) + /// an operator wants a ping for. + /// + public Task RecordTenantPlanDowngradedAsync( + string tenantId, string? domainName, string caller, DateTime? retentionGraceEndsUtc, int storedRetentionDays) + { + var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; + var graceNote = retentionGraceEndsUtc is DateTime graceEnd + ? $"retention grace until {graceEnd:yyyy-MM-dd HH:mm}Z" + : "no retention grace applicable"; + return WriteAsync(OpsEventCategory.Tenant, "TenantPlanDowngraded", OpsEventSeverity.Warning, + $"Tenant {tenantLabel} was downgraded Pro → Community by {caller} — {graceNote} (stored retention {storedRetentionDays}d)", + tenantId, caller, new { domainName, retentionGraceEndsUtc, storedRetentionDays }); + } + + /// + /// Heads-up: a downgraded tenant's retention grace ends within the next few days — + /// after that, the sweep enforces the Community cap and deletes the older data. + /// Emitted only for tenants that actually have data at risk (stored retention above + /// the Community cap). Re-fired daily during the heads-up window (no dedup state). + /// + public Task RecordTenantRetentionGraceExpiringAsync( + string tenantId, string? domainName, DateTime graceEndsUtc, int daysLeft, int storedRetentionDays) + { + var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; + return WriteAsync(OpsEventCategory.Tenant, "TenantRetentionGraceExpiring", OpsEventSeverity.Warning, + $"Retention grace for downgraded tenant {tenantLabel} ends in {daysLeft} day(s) ({graceEndsUtc:yyyy-MM-dd HH:mm}Z) — " + + $"data older than the Community cap will then be deleted (stored retention {storedRetentionDays}d)", + tenantId, "System.TrialSweep", new { domainName, graceEndsUtc, daysLeft, storedRetentionDays }); + } + + /// + /// A downgraded tenant's retention grace ended within the last sweep window — from now + /// on the retention sweep enforces the Community cap and starts deleting the older data. + /// Last call for a GA intervention (re-upgrade / retention decision). + /// + public Task RecordTenantRetentionGraceEndedAsync( + string tenantId, string? domainName, DateTime graceEndedUtc, int storedRetentionDays) + { + var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; + return WriteAsync(OpsEventCategory.Tenant, "TenantRetentionGraceEnded", OpsEventSeverity.Warning, + $"Retention grace for downgraded tenant {tenantLabel} ended ({graceEndedUtc:yyyy-MM-dd HH:mm}Z) — " + + $"the Community retention cap is now enforced and older data will be deleted (stored retention {storedRetentionDays}d)", + tenantId, "System.TrialSweep", new { domainName, graceEndedUtc, storedRetentionDays }); + } + /// /// F3 regression radar (insights spec §F3): an analyze rule's 7-day hit rate rose ≥2× /// over its 28-day baseline with disjoint Wilson intervals. Fired ONCE per episode (the diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs index d3a29048..29ce664a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs @@ -121,6 +121,7 @@ namespace AutopilotMonitor.Functions.Services "LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy", "HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince", "PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy", + "ProDowngradedUtc", }; /// @@ -148,6 +149,7 @@ namespace AutopilotMonitor.Functions.Services "HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince", "OnboardedBy", "OnboardedAt", "PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy", + "ProDowngradedUtc", }; private static readonly Dictionary ModelProperties = diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs index 207de5e0..c1b9a468 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs @@ -97,10 +97,36 @@ namespace AutopilotMonitor.Functions.Services && config.UnrestrictedModeEnabled && config.UnrestrictedMode; + /// + /// End of the retention downgrade grace period, or null when none applies (tenant is + /// effectively Pro, or never lost Pro). The anchor is the LATEST of the explicit + /// downgrade timestamp (, written by + /// the plan endpoint) and an expired trial's + /// (read-time, no write on expiry). A returned value in the past means the grace is over. + /// + public static DateTime? GetRetentionGraceEndUtc(TenantConfiguration config, DateTime nowUtc) + { + if (ResolveEdition(config, nowUtc) == TenantEdition.Pro) + return null; + + DateTime? anchor = config.ProDowngradedUtc; + if (config.TrialExpiresUtc is DateTime trialEnd && trialEnd <= nowUtc && + (anchor is null || trialEnd > anchor)) + { + anchor = trialEnd; + } + + return anchor?.AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays); + } + /// /// The retention days the platform actually enforces for this config: the stored value /// clamped to the edition's cap. days <= 0 is the GA-only "infinite" escape hatch /// and is passed through unclamped (the fanout skips those tenants entirely). + /// Downgrade grace: for + /// days after losing Pro the PRO cap keeps applying, so a downgrade or trial expiry never + /// immediately hard-deletes the 90–365-day band. Retention is the only entitlement with + /// a grace — the others are reversible gates. /// public static int GetEffectiveRetentionDays(TenantConfiguration config, DateTime nowUtc) { @@ -108,7 +134,14 @@ namespace AutopilotMonitor.Functions.Services if (days <= 0) return 0; - var cap = FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).RetentionCapDays; + var edition = ResolveEdition(config, nowUtc); + var cap = FeatureEntitlementCatalog.Get(edition).RetentionCapDays; + if (edition == TenantEdition.Community && + GetRetentionGraceEndUtc(config, nowUtc) is DateTime graceEnd && graceEnd > nowUtc) + { + cap = FeatureEntitlementCatalog.Get(TenantEdition.Pro).RetentionCapDays; + } + return Math.Min(days, cap); } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index ca7de20a..b2eab9c5 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -166,6 +166,17 @@ namespace AutopilotMonitor.Shared.Models /// public string? TrialGrantedBy { get; set; } + /// + /// When the tenant's EFFECTIVE edition last dropped Pro → Community via the plan + /// endpoint (UTC). Anchors the retention downgrade grace period: for + /// RetentionDowngradeGraceDays after losing Pro the retention cap stays at the Pro + /// value so a downgrade (e.g. non-payment) does not immediately hard-delete data + /// older than the Community cap. Trial expiry needs no write — TrialExpiresUtc + /// itself is the anchor there. Cleared whenever the tenant becomes effectively Pro + /// again. Backend-only: not delivered to the agent (no ConfigVersion impact). + /// + public DateTime? ProDowngradedUtc { get; set; } + /// /// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") /// Comma-separated list diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx index 478c934b..d8b5ec57 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx @@ -135,6 +135,15 @@ const OPS_EVENT_TYPES: Record = { // (AutoApproveNewTenants enabled). Info-tier. Dual-register per memory // feedback_ops_event_types_dual_register. "TenantAutoApproved", + // Plan downgrade / retention grace. Downgraded is dispatched by PlanManagementFunction + // (RecordTenantPlanDowngradedAsync) when a GA plan mutation drops the effective edition + // Pro → Community; the grace events by TrialExpirySweepFunction — Expiring during the + // ≤3-day heads-up (re-emitted daily), Ended once via the 24h look-back, both only when + // stored retention exceeds the Community cap (data actually at risk). All Warning-tier. + // Dual-register per memory feedback_ops_event_types_dual_register. + "TenantPlanDowngraded", + "TenantRetentionGraceExpiring", + "TenantRetentionGraceEnded", ], Agent: [ "BlobStorageMissing", diff --git a/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts index 19702025..4113b6c1 100644 --- a/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts +++ b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts @@ -59,6 +59,7 @@ const SERVER_DENIED_FIELDS = new Set([ "lastUpdated", "updatedBy", "onboardedAt", "onboardedBy", "homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince", "planTier", "trialExpiresUtc", "trialStartedUtc", "trialConsumed", "trialGrantedBy", + "proDowngradedUtc", ]); describe("SECTION_FIELD_MAP parity", () => { diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index 1664be4c..f64ea50d 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -80,6 +80,7 @@ export const SHARED_MANIFEST = { "trialStartedUtc", "trialConsumed", "trialGrantedBy", + "proDowngradedUtc", "manufacturerWhitelist", "modelWhitelist", "validateAutopilotDevice", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 33596a2c..2356f3f6 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -76,6 +76,7 @@ "trialStartedUtc", "trialConsumed", "trialGrantedBy", + "proDowngradedUtc", "manufacturerWhitelist", "modelWhitelist", "validateAutopilotDevice",