From 3628c7bc791fb950b01dcfe5f4629334f327f6a2 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 3 Aug 2026 14:45:11 +0200 Subject: [PATCH] fix(backend): revert rehydration - whole-valued double columns must not become Int32 Prod finding during the first live E2E (gktatooine): revert_tenant_config failed fail-closed with "could not be rehydrated". Root cause: JSON cannot distinguish 95.0 from 95, so the snapshot rehydrator turned the whole-valued SLA rate columns (decimal? in the model, double in storage) into Int32 - and TableEntity.GetDouble throws on the type mismatch. The rehydrator now forces GetDouble for columns the model declares as decimal/double/float, mirroring the existing DateTime handling. The roundtrip canary test is hardened into a reflection filler that sets EVERY writable property non-default (with a deliberately whole-numbered decimal), so no column type can escape the fidelity check again; plus a focused pin on the exact prod failure. Suite 3849 green. No data was harmed: the fail-closed guard refused the write, the snapshot itself is intact. Co-Authored-By: Claude Fable 5 --- .../TenantConfigPatchServiceTests.cs | 56 +++++++++++++++---- .../Services/TenantConfigPatchService.cs | 17 +++++- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs index fbc19f35..d83fdb01 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs @@ -488,24 +488,60 @@ public class TenantConfigPatchServiceTests // ── Rehydration roundtrip (serialization-drift canary) ───────────────── [Fact] - public void RehydrateEntity_Roundtrip_ProducesIdenticalModel() + public void RehydrateEntity_Roundtrip_EveryPropertyNonNull_ProducesIdenticalModel() { - var original = Stored(); - original.ContactEmail = "ops@contoso.com"; - original.TrialExpiresUtc = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc); - original.LastAuthClientIdSince = new DateTime(2026, 7, 31, 18, 3, 0, DateTimeKind.Utc); - original.CustomRateLimitRequestsPerMinute = 42; + // Reflection filler: EVERY writable property gets a non-default value so no column + // escapes the fidelity check. Whole-number decimals (95m) are the deliberate trap — + // JSON serializes 95.0 as "95", and prod finding 2026-08-03 was exactly a whole-valued + // SLA rate column rehydrating as Int32 and blowing up TableEntity.GetDouble. + var original = TenantConfiguration.CreateDefault(TenantId); + foreach (var prop in typeof(TenantConfiguration).GetProperties() + .Where(p => p.CanWrite && p.GetIndexParameters().Length == 0)) + { + var t = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; + object? value = + t == typeof(string) ? (prop.Name is "PartitionKey" or "RowKey" or "TenantId" + ? prop.GetValue(original) : $"x-{prop.Name}") + : t == typeof(bool) ? true + : t == typeof(int) ? 7 + : t == typeof(decimal) ? 95m // whole number on purpose — the Int32/Double trap + : t == typeof(double) ? 95d + : t == typeof(DateTime) ? new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc) + : prop.GetValue(original); + prop.SetValue(original, value); + } + + // Canonical baseline = one Store/Map roundtrip (isolates REHYDRATION fidelity from any + // pre-existing converter quirks, which TenantConfigTableSerializationTests own). + var baseline = TableConfigRepository.ConvertFromTenantTableEntity( + TableConfigRepository.ConvertToTenantTableEntity(original)); + + var snapshot = TableConfigRepository.BuildBackupEntry( + TableConfigRepository.ConvertToTenantTableEntity(baseline), + TenantId, Ga, "test", null, null); + var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity( + TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId)); + + var diff = AutopilotMonitor.Functions.Helpers.ConfigPropertyComparer + .GetChangedPropertyNames(baseline, rehydrated); + Assert.True(diff.Count == 0, "Rehydration drift: " + string.Join(", ", diff)); + } + + [Fact] + public void RehydrateEntity_WholeNumberDecimalColumn_SurvivesAsDouble() + { + // Focused pin on the prod failure: SlaTargetSuccessRate 95 (whole) must come back as + // a readable double column, not an Int32 that makes GetDouble throw. + var original = Stored(); + original.SlaTargetSuccessRate = 95m; - // Snapshot exactly as the backup hook does, then rehydrate and re-map. var snapshot = TableConfigRepository.BuildBackupEntry( TableConfigRepository.ConvertToTenantTableEntity(original), TenantId, Ga, "test", null, null); var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity( TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId)); - var diff = AutopilotMonitor.Functions.Helpers.ConfigPropertyComparer - .GetChangedPropertyNames(original, rehydrated); - Assert.True(diff.Count == 0, "Rehydration drift: " + string.Join(", ", diff)); + Assert.Equal(95m, rehydrated.SlaTargetSuccessRate); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs index 9a9d2e8d..3be48d54 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs @@ -395,7 +395,11 @@ namespace AutopilotMonitor.Functions.Services /// /// Schema-aware rehydration of a raw EntityJson snapshot back into a TableEntity. /// Strings are converted to DateTime ONLY for columns the model declares as DateTime — - /// an arbitrary string field that merely looks like a date stays a string. + /// an arbitrary string field that merely looks like a date stays a string. Numbers are + /// forced to double for columns the model declares as decimal/double/float: JSON cannot + /// distinguish 95.0 from 95, so a whole-valued double column would otherwise rehydrate + /// as Int32 and TableEntity.GetDouble throws on the type mismatch (prod finding + /// 2026-08-03, SLA rate columns). /// internal static TableEntity RehydrateTenantConfigEntity(string entityJson, string tenantId) { @@ -403,6 +407,14 @@ namespace AutopilotMonitor.Functions.Services .Where(p => p.PropertyType == typeof(DateTime) || p.PropertyType == typeof(DateTime?)) .Select(p => p.Name) .ToHashSet(StringComparer.OrdinalIgnoreCase); + var doubleColumns = ModelProperties.Values + .Where(p => + { + var t = Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType; + return t == typeof(decimal) || t == typeof(double) || t == typeof(float); + }) + .Select(p => p.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); using var doc = System.Text.Json.JsonDocument.Parse(entityJson); var entity = new TableEntity(tenantId, "config"); @@ -415,7 +427,8 @@ namespace AutopilotMonitor.Functions.Services System.Text.Json.JsonValueKind.True => true, System.Text.Json.JsonValueKind.False => false, System.Text.Json.JsonValueKind.Number => - prop.Value.TryGetInt32(out var i) ? i + doubleColumns.Contains(prop.Name) ? prop.Value.GetDouble() + : prop.Value.TryGetInt32(out var i) ? i : prop.Value.TryGetInt64(out var l) ? (object)l : prop.Value.GetDouble(), System.Text.Json.JsonValueKind.String =>