mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
+2
@@ -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).
|
||||
|
||||
+85
-31
@@ -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,
|
||||
|
||||
+4
-3
@@ -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 <= 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",
|
||||
|
||||
Reference in New Issue
Block a user