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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-03 14:45:11 +02:00
parent 395fdd49df
commit 3628c7bc79
2 changed files with 61 additions and 12 deletions
@@ -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]
@@ -395,7 +395,11 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// 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).
/// </summary>
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 =>