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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-22 21:00:53 +02:00
parent 4e54ffe4b4
commit 445ce4e75b
21 changed files with 761 additions and 64 deletions
@@ -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`
+1
View File
@@ -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.
+4
View File
@@ -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:<guid>` StartupEventGate fingerprint and updated after a gateway change. Gateway heuristic extracted into the shared `NetworkInterfaceLocator` (dedup of NetworkChangeDetector + DeviceInfoCollector copies).
@@ -0,0 +1,145 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="PlanManagementFunction.ApplyPlanChanges"/> — the pure mutation core
/// of the PATCH plan endpoint. Pins the retention-grace anchor lifecycle: an EFFECTIVE
/// Pro→Community transition stamps <see cref="TenantConfiguration.ProDowngradedUtc"/>, 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).
/// </summary>
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<string, string> Changes) Apply(
TenantConfiguration config, string? planTier = null, bool trialProvided = false, DateTime? trialExpiresUtc = null)
{
var changes = new Dictionary<string, string>();
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<string, string>();
var later = Now.AddDays(40);
PlanManagementFunction.ApplyPlanChanges(config, "community", false, null, Caller, later, changes);
Assert.Equal(later, config.ProDowngradedUtc);
}
}
@@ -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<SessionSummary>();
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<SessionSummary>(),
planTier: "community", proDowngradedUtc: now.AddDays(-10));
await harness.RunAsync();
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
"t1", now.AddDays(-365), It.IsAny<int>(), It.IsAny<bool>()), 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<SessionSummary>(),
planTier: "community", proDowngradedUtc: now.AddDays(-31));
await harness.RunAsync();
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
"t1", now.AddDays(-90), It.IsAny<int>(), It.IsAny<bool>()), 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<DateTime>(), It.IsAny<int>(), It.IsAny<bool>()))
.ReturnsAsync(new List<SessionSummary>(WithTenantId(tenantId, sessions)));
}
@@ -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]
@@ -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)));
}
}
@@ -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<OpsEventEntry> 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()
{
@@ -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"),
@@ -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<PlanManagementFunction> 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<string, string>();
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
}
}
/// <summary>
/// Pure mutation core of the PATCH plan endpoint — no I/O, unit-testable. Applies the
/// plan/trial changes to <paramref name="config"/>, records human-readable entries in
/// <paramref name="changes"/>, and returns the EFFECTIVE edition before/after (same
/// <paramref name="nowUtc"/> for both, so only the mutation itself can flip it).
///
/// Maintains the retention downgrade grace anchor: an effective Pro→Community
/// transition stamps <see cref="TenantConfiguration.ProDowngradedUtc"/> (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.
/// </summary>
internal static (TenantEdition Before, TenantEdition After) ApplyPlanChanges(
TenantConfiguration config,
string? newPlanTier,
bool trialProvided,
DateTime? newTrialExpiresUtc,
string caller,
DateTime nowUtc,
Dictionary<string, string> 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);
}
/// <summary>
/// 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).
@@ -10,15 +10,21 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Maintenance;
/// <summary>
/// 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:
/// <list type="bullet">
/// <item><c>TenantTrialExpired</c> (Warning) — the trial ended within the last sweep window
/// (24h look-back matching the daily cadence).</item>
/// <item><c>TenantTrialExpiring</c> (Info) — the trial ends within the next
/// <see cref="ExpiringHeadsUpDays"/> days (re-emitted each daily run until expiry —
/// acceptable for an Info-tier heads-up, no dedupe state to maintain).</item>
/// <item><c>TenantRetentionGraceExpiring</c> / <c>TenantRetentionGraceEnded</c> (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).</item>
/// </list>
/// Tenants whose stored <c>PlanTier</c> 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++;
}
}
/// <summary>
/// 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.
/// </summary>
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>Summary counters returned from <see cref="RunCoreAsync"/> so tests can assert outcomes.</summary>
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; }
}
}
@@ -95,6 +95,16 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
public const string LegacyEnterpriseTierName = "enterprise";
/// <summary>
/// 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 <see cref="Services.TenantEntitlementService.GetRetentionGraceEndUtc"/>.
/// </summary>
public const int RetentionDowngradeGraceDays = 30;
private static readonly EditionEntitlements Community = new()
{
Edition = TenantEdition.Community,
@@ -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
@@ -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.
/// <summary>
/// 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.
/// </summary>
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 });
}
/// <summary>
/// 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).
/// </summary>
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 });
}
/// <summary>
/// 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).
/// </summary>
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 });
}
/// <summary>
/// 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
@@ -121,6 +121,7 @@ namespace AutopilotMonitor.Functions.Services
"LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy",
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
"ProDowngradedUtc",
};
/// <summary>
@@ -148,6 +149,7 @@ namespace AutopilotMonitor.Functions.Services
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
"OnboardedBy", "OnboardedAt",
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
"ProDowngradedUtc",
};
private static readonly Dictionary<string, PropertyInfo> ModelProperties =
@@ -97,10 +97,36 @@ namespace AutopilotMonitor.Functions.Services
&& config.UnrestrictedModeEnabled
&& config.UnrestrictedMode;
/// <summary>
/// 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 (<see cref="TenantConfiguration.ProDowngradedUtc"/>, written by
/// the plan endpoint) and an expired trial's <see cref="TenantConfiguration.TrialExpiresUtc"/>
/// (read-time, no write on expiry). A returned value in the past means the grace is over.
/// </summary>
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);
}
/// <summary>
/// The retention days the platform actually enforces for this config: the stored value
/// clamped to the edition's cap. <c>days &lt;= 0</c> is the GA-only "infinite" escape hatch
/// and is passed through unclamped (the fanout skips those tenants entirely).
/// Downgrade grace: for <see cref="FeatureEntitlementCatalog.RetentionDowngradeGraceDays"/>
/// 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.
/// </summary>
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);
}
}
@@ -166,6 +166,17 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string? TrialGrantedBy { get; set; }
/// <summary>
/// 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).
/// </summary>
public DateTime? ProDowngradedUtc { get; set; }
/// <summary>
/// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*")
/// Comma-separated list
@@ -135,6 +135,15 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
// (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",
@@ -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", () => {
@@ -80,6 +80,7 @@ export const SHARED_MANIFEST = {
"trialStartedUtc",
"trialConsumed",
"trialGrantedBy",
"proDowngradedUtc",
"manufacturerWhitelist",
"modelWhitelist",
"validateAutopilotDevice",
@@ -76,6 +76,7 @@
"trialStartedUtc",
"trialConsumed",
"trialGrantedBy",
"proDowngradedUtc",
"manufacturerWhitelist",
"modelWhitelist",
"validateAutopilotDevice",