diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BlobStorageServiceDeletionManifestTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BlobStorageServiceDeletionManifestTests.cs
new file mode 100644
index 00000000..5dad4406
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/BlobStorageServiceDeletionManifestTests.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure;
+using Azure.Storage.Blobs;
+using Azure.Storage.Blobs.Models;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.Models.Deletion;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Verifies the gzip + SHA-256 + IfNoneMatch behaviour of the new manifest upload/download
+/// helpers on . Uses a small subclass that overrides the two
+/// virtual blob-IO seams — much simpler than wrestling with BlobsModelFactory's
+/// shifting parameter list, and matches repo convention (no Azurite).
+///
+public class BlobStorageServiceDeletionManifestTests
+{
+ private const string TenantId = "11111111-1111-1111-1111-111111111111";
+ private const string SessionId = "22222222-2222-2222-2222-222222222222";
+
+ [Fact]
+ public async Task Upload_then_download_round_trips_manifest()
+ {
+ var manifest = SampleManifest();
+ var sut = new FakeBlobStorageService();
+
+ var pointer = await sut.UploadDeletionManifestAsync(manifest);
+
+ Assert.Equal($"{TenantId}/{SessionId}/{manifest.ManifestId}.snapshot.json.gz", pointer.BlobName);
+ Assert.Equal("deletion-manifests", pointer.ContainerName);
+ Assert.True(pointer.SizeBytes > 0);
+ Assert.Matches("^[0-9a-f]{64}$", pointer.SnapshotSha256);
+ Assert.NotNull(sut.LastWrittenBytes);
+ Assert.Equal(sut.LastWrittenBytes!.Length, pointer.SizeBytes);
+
+ // Replay back through Download — uses the bytes the Upload captured.
+ var roundTripped = await sut.DownloadDeletionManifestAsync(TenantId, SessionId, manifest.ManifestId);
+
+ Assert.Equal(manifest.ManifestId, roundTripped.ManifestId);
+ Assert.Equal(manifest.TenantId, roundTripped.TenantId);
+ Assert.Equal(manifest.SessionId, roundTripped.SessionId);
+ Assert.Equal(manifest.Reason, roundTripped.Reason);
+ Assert.Equal(manifest.Steps.Count, roundTripped.Steps.Count);
+ Assert.Equal(manifest.SchemaHash, roundTripped.SchemaHash);
+ }
+
+ [Fact]
+ public async Task Upload_sets_content_encoding_gzip_and_sha256_metadata()
+ {
+ var manifest = SampleManifest();
+ var sut = new FakeBlobStorageService();
+
+ await sut.UploadDeletionManifestAsync(manifest);
+
+ Assert.NotNull(sut.LastWrittenOptions);
+ Assert.Equal("application/json", sut.LastWrittenOptions!.HttpHeaders!.ContentType);
+ Assert.Equal("gzip", sut.LastWrittenOptions.HttpHeaders.ContentEncoding);
+ Assert.NotNull(sut.LastWrittenOptions.Metadata);
+ Assert.True(sut.LastWrittenOptions.Metadata!.ContainsKey("sha256"));
+ Assert.Matches("^[0-9a-f]{64}$", sut.LastWrittenOptions.Metadata["sha256"]);
+ // Plan §1 P1: snapshot is written exactly once → IfNoneMatch=* makes overwrites fail loud.
+ Assert.NotNull(sut.LastWrittenOptions.Conditions);
+ Assert.Equal(ETag.All, sut.LastWrittenOptions.Conditions!.IfNoneMatch);
+ }
+
+ [Fact]
+ public async Task Upload_payload_is_gzipped_and_decompresses_to_canonical_manifest_json()
+ {
+ var manifest = SampleManifest();
+ var sut = new FakeBlobStorageService();
+
+ await sut.UploadDeletionManifestAsync(manifest);
+
+ var captured = sut.LastWrittenBytes;
+ Assert.NotNull(captured);
+ // Magic bytes 0x1F 0x8B identify the payload as GZip.
+ Assert.Equal(0x1F, captured![0]);
+ Assert.Equal(0x8B, captured[1]);
+
+ // Gunzip and confirm the bytes are valid manifest JSON.
+ using var input = new MemoryStream(captured);
+ using var gunzip = new GZipStream(input, CompressionMode.Decompress);
+ using var output = new MemoryStream();
+ gunzip.CopyTo(output);
+ var decoded = JsonSerializer.Deserialize(output.ToArray(), DeletionManifestJson.SerializerOptions);
+ Assert.NotNull(decoded);
+ Assert.Equal(manifest.ManifestId, decoded!.ManifestId);
+ }
+
+ [Fact]
+ public async Task Download_fails_loud_on_sha256_mismatch()
+ {
+ var manifest = SampleManifest();
+ var sut = new FakeBlobStorageService();
+ await sut.UploadDeletionManifestAsync(manifest);
+
+ // Tamper with the captured metadata so the recorded sha differs from the actual hash.
+ sut.LastWrittenOptions!.Metadata!["sha256"] = new string('0', 64);
+
+ var ex = await Assert.ThrowsAsync(
+ () => sut.DownloadDeletionManifestAsync(TenantId, SessionId, manifest.ManifestId));
+ Assert.Contains("SHA-256 mismatch", ex.Message);
+ }
+
+ [Fact]
+ public async Task Download_fails_loud_when_sha256_metadata_missing()
+ {
+ var manifest = SampleManifest();
+ var sut = new FakeBlobStorageService();
+ await sut.UploadDeletionManifestAsync(manifest);
+
+ sut.LastWrittenOptions!.Metadata!.Remove("sha256");
+
+ var ex = await Assert.ThrowsAsync(
+ () => sut.DownloadDeletionManifestAsync(TenantId, SessionId, manifest.ManifestId));
+ Assert.Contains("missing the required 'sha256' metadata", ex.Message);
+ }
+
+ [Fact]
+ public async Task Upload_fails_loud_on_overwrite_attempt()
+ {
+ var manifest = SampleManifest();
+ var sut = new FakeBlobStorageService
+ {
+ // Simulate the IfNoneMatch=* race — second producer for the same manifestId hits 409.
+ UploadException = new RequestFailedException(409, "BlobAlreadyExists", "BlobAlreadyExists", innerException: null),
+ };
+
+ var ex = await Assert.ThrowsAsync(
+ () => sut.UploadDeletionManifestAsync(manifest));
+ Assert.Equal(409, ex.Status);
+ }
+
+ [Fact]
+ public async Task Upload_throws_on_missing_required_fields()
+ {
+ var sut = new FakeBlobStorageService();
+
+ await Assert.ThrowsAsync(
+ () => sut.UploadDeletionManifestAsync(null!));
+ await Assert.ThrowsAsync(
+ () => sut.UploadDeletionManifestAsync(new DeletionManifest()));
+ }
+
+ // ---------------------------------------------------------------- Test fixture ----
+
+ private static DeletionManifest SampleManifest() => new DeletionManifest
+ {
+ ManifestId = "0123456789ABCDEF_FEDCBA9876543210",
+ TenantId = TenantId,
+ SessionId = SessionId,
+ CreatedAt = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc),
+ CreatedBy = new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ Reason = "admin_delete",
+ RetentionContext = new DeletionRetentionContext { TenantRetentionDays = 90 },
+ Steps = new List
+ {
+ new DeletionStep { Order = 1, Table = "Events", Class = DeletionStepClass.PkBySession, RowCount = 0 },
+ },
+ SchemaHash = "sha256:placeholder",
+ };
+
+ ///
+ /// Captures the bytes + options written by Upload and replays them on Download. The
+ /// production overrides hit Azure Blob Storage; this fake bypasses the SDK entirely so
+ /// tests assert behaviour without sweating the BlobsModelFactory shape.
+ ///
+ private sealed class FakeBlobStorageService : BlobStorageService
+ {
+ public byte[]? LastWrittenBytes { get; private set; }
+ public BlobUploadOptions? LastWrittenOptions { get; private set; }
+ public Exception? UploadException { get; set; }
+
+ public FakeBlobStorageService()
+ : base(new BlobServiceClient("UseDevelopmentStorage=true"), NullLogger.Instance, usesManagedIdentity: false)
+ {
+ }
+
+ protected internal override Task WriteDeletionManifestBlobAsync(
+ string blobName, byte[] gzipped, BlobUploadOptions options, CancellationToken cancellationToken)
+ {
+ if (UploadException != null) throw UploadException;
+ LastWrittenBytes = gzipped;
+ LastWrittenOptions = options;
+ return Task.CompletedTask;
+ }
+
+ protected internal override Task<(byte[] Gzipped, IDictionary? Metadata)>
+ ReadDeletionManifestBlobAsync(string blobName, CancellationToken cancellationToken)
+ {
+ if (LastWrittenBytes == null)
+ {
+ throw new InvalidOperationException("Test must call UploadDeletionManifestAsync before DownloadDeletionManifestAsync.");
+ }
+ return Task.FromResult<(byte[], IDictionary?)>(
+ (LastWrittenBytes, LastWrittenOptions?.Metadata));
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs
new file mode 100644
index 00000000..0c86f446
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs
@@ -0,0 +1,848 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure;
+using Azure.Data.Tables;
+using AutopilotMonitor.Functions.Services.Deletion;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models.Deletion;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Behaviour tests for . PR1 is read-only — the builder
+/// must (a) enumerate every cascade table per the §3 taxonomy, (b) only emit the
+/// SoftwareInventoryDecrement / SessionInventoryContributions pair when the side-row exists,
+/// and (c) order the FINAL tombstone step SessionsIndex-then-Sessions. All tests are
+/// Moq-based against the new seam — no Azurite,
+/// matching repo convention.
+///
+public class DeletionManifestBuilderTests
+{
+ private const string TenantId = "11111111-1111-1111-1111-111111111111";
+ private const string SessionId = "22222222-2222-2222-2222-222222222222";
+
+ [Fact]
+ public async Task Build_returns_correct_counts_for_full_session()
+ {
+ var reader = NewReader(seed => seed
+ .WithSessionsRow()
+ .WithSessionsIndexRow()
+ .WithEvents(7)
+ .WithRuleResults(2)
+ .WithAppInstallSummaries(3)
+ .WithVulnerabilityReport()
+ .WithDeviceSnapshot()
+ .WithEventSessionIndex()
+ .WithSignals(15)
+ .WithDecisionTransitions(4)
+ .WithEventTypeIndex(5)
+ .WithCveIndex(2)
+ .WithSessionsByTerminal(1)
+ .WithSessionsByStage(3)
+ .WithDeadEndsByReason(0)
+ .WithClassifierVerdictsByIdLevel(2)
+ .WithSignalsByKind(11));
+
+ var manifest = await NewBuilder(reader).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ var c = manifest.PreflightCounts;
+ Assert.Equal(7, c["events"]);
+ Assert.Equal(2, c["ruleResults"]);
+ Assert.Equal(3, c["appInstallSummaries"]);
+ Assert.Equal(1, c["vulnerabilityReports"]);
+ Assert.Equal(1, c["deviceSnapshot"]);
+ Assert.Equal(1, c["eventSessionIndex"]);
+ Assert.Equal(15, c["signals"]);
+ Assert.Equal(4, c["decisionTransitions"]);
+ Assert.Equal(5, c["eventTypeIndex"]);
+ Assert.Equal(2, c["cveIndex"]);
+ Assert.Equal(1, c["sessionsByTerminal"]);
+ Assert.Equal(3, c["sessionsByStage"]);
+ Assert.Equal(0, c["deadEndsByReason"]);
+ Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
+ Assert.Equal(11, c["signalsByKind"]);
+
+ // 15 cascade tables + tombstone = 16 steps; no inventory steps when no contributions row.
+ Assert.Equal(16, manifest.Steps.Count);
+ Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
+ }
+
+ [Fact]
+ public async Task Build_returns_full_row_dumps_for_all_classes()
+ {
+ // Seed one row in each non-inventory table with a recognisable property so we can
+ // assert that the dump path captures its value.
+ var reader = NewReader(seed => seed
+ .WithSessionsRow()
+ .WithSessionsIndexRow()
+ .WithEvents(1, propValue: "events_marker")
+ .WithRuleResults(1, propValue: "rules_marker")
+ .WithAppInstallSummaries(1, propValue: "appinstalls_marker")
+ .WithVulnerabilityReport(propValue: "vuln_marker")
+ .WithDeviceSnapshot(propValue: "device_marker")
+ .WithEventSessionIndex(propValue: "esi_marker")
+ .WithSignals(1, propValue: "signals_marker")
+ .WithDecisionTransitions(1, propValue: "transitions_marker")
+ .WithEventTypeIndex(1, propValue: "eti_marker")
+ .WithCveIndex(1, propValue: "cve_marker")
+ .WithSessionsByTerminal(1, propValue: "terminal_marker")
+ .WithSessionsByStage(1, propValue: "stage_marker")
+ .WithDeadEndsByReason(1, propValue: "deadend_marker")
+ .WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
+ .WithSignalsByKind(1, propValue: "signalkind_marker"));
+
+ var manifest = await NewBuilder(reader).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ foreach (var step in manifest.Steps)
+ {
+ // For the FINAL tombstone we expect 2 rows (SessionsIndex + Sessions); other steps each have 1.
+ if (step.Class == DeletionStepClass.Final)
+ {
+ Assert.Equal(2, step.Rows.Count);
+ continue;
+ }
+ Assert.Equal(step.RowCount, step.Rows.Count);
+ Assert.Single(step.Rows);
+
+ var dump = step.Rows[0];
+ Assert.False(string.IsNullOrEmpty(dump.Pk));
+ Assert.False(string.IsNullOrEmpty(dump.Rk));
+ Assert.False(string.IsNullOrEmpty(dump.Etag));
+ Assert.True(dump.Props.ContainsKey("Marker"),
+ $"Step {step.Order} ({step.Table ?? step.Step}) row dump is missing the Marker prop.");
+ Assert.Equal(DeletionPropEdmType.String, dump.Props["Marker"].EdmType);
+ Assert.Equal(JsonValueKind.String, dump.Props["Marker"].Value.ValueKind);
+ // The seed marker round-trips faithfully through the property → DeletionPropValue convertor.
+ Assert.False(string.IsNullOrEmpty(dump.Props["Marker"].Value.GetString()));
+ }
+ }
+
+ [Fact]
+ public async Task Build_returns_empty_rows_for_tables_with_no_session_data()
+ {
+ // No data anywhere — the builder still emits one step per cascade table, all with RowCount=0.
+ var reader = NewReader(seed => seed.WithSessionsRow().WithSessionsIndexRow());
+
+ var manifest = await NewBuilder(reader).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
+ // Without a contributions row, neither inventory step is emitted → exactly 15 cascade-table steps.
+ Assert.Equal(15, nonTombstoneSteps.Count);
+ foreach (var step in nonTombstoneSteps)
+ {
+ Assert.Equal(0, step.RowCount);
+ Assert.Empty(step.Rows);
+ }
+ }
+
+ [Fact]
+ public async Task Build_emits_inventory_steps_when_side_row_present()
+ {
+ var sideRowKeys = new List
+ {
+ new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" },
+ new DeletionDecrementKey { Vendor = "Fabrikam", Name = "Gadget", Version = "2.0" },
+ new DeletionDecrementKey { Vendor = "Tailspin", Name = "Service", Version = "3.0" },
+ };
+ var sideRow = MakeContributionsRow(sideRowKeys, compressed: false);
+
+ var reader = NewReader(seed => seed
+ .WithSessionsRow()
+ .WithSessionsIndexRow()
+ .WithSessionInventoryContributions(sideRow));
+
+ var manifest = await NewBuilder(reader).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ // Expect: 15 cascade + 2 inventory + 1 tombstone = 18 steps.
+ Assert.Equal(18, manifest.Steps.Count);
+
+ var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
+ Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
+ Assert.NotNull(aggregate.Decrements);
+ Assert.Equal(3, aggregate.Decrements!.Count);
+ // Builder sorts deterministically by (Vendor, Name, Version).
+ Assert.Equal("Contoso", aggregate.Decrements[0].Vendor);
+ Assert.Equal("Fabrikam", aggregate.Decrements[1].Vendor);
+ Assert.Equal("Tailspin", aggregate.Decrements[2].Vendor);
+ Assert.Empty(aggregate.Rows);
+
+ var contribStep = manifest.Steps.Single(s => s.Table == Constants.TableNames.SessionInventoryContributions);
+ Assert.Equal(DeletionStepClass.PkRkExact, contribStep.Class);
+ Assert.Single(contribStep.Rows);
+ Assert.Equal(TenantId, contribStep.Rows[0].Pk);
+ Assert.Equal(SessionId, contribStep.Rows[0].Rk);
+
+ Assert.Equal(3, manifest.PreflightCounts["softwareInventoryDecrement"]);
+ Assert.Equal(1, manifest.PreflightCounts["sessionInventoryContributions"]);
+ }
+
+ [Fact]
+ public async Task Build_emits_inventory_steps_when_side_row_is_gzip_compressed()
+ {
+ var sideRowKeys = new List
+ {
+ new DeletionDecrementKey { Vendor = "Contoso", Name = "WidgetSuite", Version = "10.5" },
+ new DeletionDecrementKey { Vendor = "Tailspin", Name = "ToolKit", Version = "0.9" },
+ };
+ var sideRow = MakeContributionsRow(sideRowKeys, compressed: true);
+
+ var reader = NewReader(seed => seed
+ .WithSessionsRow()
+ .WithSessionsIndexRow()
+ .WithSessionInventoryContributions(sideRow));
+
+ var manifest = await NewBuilder(reader).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
+ Assert.Equal(2, aggregate.Decrements!.Count);
+ Assert.Equal("Contoso", aggregate.Decrements[0].Vendor);
+ Assert.Equal("Tailspin", aggregate.Decrements[1].Vendor);
+ }
+
+ [Fact]
+ public async Task Build_omits_inventory_steps_when_no_side_row()
+ {
+ // The default reader returns no inventory row → 15 cascade + 1 tombstone, no inventory steps.
+ var reader = NewReader(seed => seed.WithSessionsRow().WithSessionsIndexRow());
+
+ var manifest = await NewBuilder(reader).BuildAsync(
+ TenantId, SessionId, "retention_cutoff",
+ new DeletionActor { Type = "maintenance", Actor = "system" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ Assert.DoesNotContain(manifest.Steps, s => s.Class == DeletionStepClass.Aggregate);
+ Assert.DoesNotContain(manifest.Steps, s => s.Table == Constants.TableNames.SessionInventoryContributions);
+ Assert.False(manifest.PreflightCounts.ContainsKey("softwareInventoryDecrement"));
+ Assert.False(manifest.PreflightCounts.ContainsKey("sessionInventoryContributions"));
+ }
+
+ [Fact]
+ public async Task Build_records_diagnosticsBlobName_when_present_but_no_delete_step_emitted()
+ {
+ const string blobName = "diagnostics-uploads/tenant/session.zip";
+ var reader = NewReader(seed => seed
+ .WithSessionsRow(diagnosticsBlobName: blobName)
+ .WithSessionsIndexRow());
+
+ var manifest = await NewBuilder(reader).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ Assert.Equal(blobName, manifest.DiagnosticsBlobName);
+
+ // No cascade step targets the diagnostics blob — customer storage, customer cleans up
+ // (plan §1 P10 / §3 / §11). The blob name is captured for forensic traceability only.
+ // It IS expected to appear as a property value on the Sessions row dump in the Tombstone
+ // step (the row is being deleted with all its columns intact); that's a row-content
+ // property, not a "the cascade will delete this blob" signal.
+ foreach (var step in manifest.Steps)
+ {
+ Assert.NotEqual("diagnostics", step.Table);
+ Assert.NotEqual("DiagnosticsUploads", step.Table);
+ }
+ }
+
+ [Fact]
+ public async Task Build_tombstone_step_orders_sessionsIndex_before_sessions()
+ {
+ var reader = NewReader(seed => seed.WithSessionsRow().WithSessionsIndexRow());
+
+ var manifest = await NewBuilder(reader).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ var tombstone = manifest.Steps.Last();
+ Assert.Equal(DeletionStepClass.Final, tombstone.Class);
+ Assert.Equal(DeletionStepNames.Tombstone, tombstone.Step);
+ Assert.Equal(2, tombstone.Rows.Count);
+
+ // Plan §5 PR4: SessionsIndex is row[0], Sessions is row[1] — UI listings drop the session before the canonical row goes.
+ // We seeded the Sessions row at (TenantId, SessionId) and the SessionsIndex row at (TenantId, "{SessionId}").
+ Assert.NotEqual(SessionId, tombstone.Rows[0].Rk); // SessionsIndex first
+ Assert.Contains(SessionId, tombstone.Rows[0].Rk, StringComparison.Ordinal); // RK still references the session
+ Assert.Equal(SessionId, tombstone.Rows[1].Rk); // Sessions row second
+ }
+
+ [Fact]
+ public async Task Build_produces_identical_steps_and_counts_across_runs_with_identical_data()
+ {
+ // Determinism guarantee per plan §18.6: the builder is read-only and produces the same
+ // manifest given the same input. Volatile fields (ManifestId, CreatedAt, SchemaHash —
+ // which is computed over those volatile fields) intentionally differ.
+ var reader = NewReader(seed => seed
+ .WithSessionsRow()
+ .WithSessionsIndexRow()
+ .WithEvents(3)
+ .WithSignals(5)
+ .WithSessionsByTerminal(1));
+
+ var first = await NewBuilder(reader).BuildAsync(TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+ var second = await NewBuilder(reader).BuildAsync(TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ Assert.NotEqual(first.ManifestId, second.ManifestId);
+ Assert.Equal(first.PreflightCounts, second.PreflightCounts);
+ Assert.Equal(first.Steps.Count, second.Steps.Count);
+ for (int i = 0; i < first.Steps.Count; i++)
+ {
+ Assert.Equal(first.Steps[i].Order, second.Steps[i].Order);
+ Assert.Equal(first.Steps[i].Class, second.Steps[i].Class);
+ Assert.Equal(first.Steps[i].Table, second.Steps[i].Table);
+ Assert.Equal(first.Steps[i].Step, second.Steps[i].Step);
+ Assert.Equal(first.Steps[i].RowCount, second.Steps[i].RowCount);
+ Assert.Equal(first.Steps[i].Rows.Count, second.Steps[i].Rows.Count);
+ }
+ }
+
+ [Fact]
+ public async Task Restore_from_manifest_round_trips_session()
+ {
+ // PR1 ships the manifest as a backup format. The Azure-side re-insertion is exercised by
+ // PR4's worker tests; here we verify the manifest is sufficient AS the backup —
+ // serialization round-trip preserves every (PK, RK, props, ETag) needed to replay.
+ var reader = NewReader(seed => seed
+ .WithSessionsRow()
+ .WithSessionsIndexRow()
+ .WithEvents(2, propValue: "events_check")
+ .WithRuleResults(1, propValue: "rules_check")
+ .WithSessionsByTerminal(1, propValue: "terminal_check")
+ .WithSessionInventoryContributions(MakeContributionsRow(new List
+ {
+ new DeletionDecrementKey { Vendor = "Contoso", Name = "App", Version = "1.0" },
+ }, compressed: false)));
+
+ var manifest = await NewBuilder(reader).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ var json = JsonSerializer.SerializeToUtf8Bytes(manifest, DeletionManifestJson.SerializerOptions);
+ var roundTripped = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions);
+
+ Assert.NotNull(roundTripped);
+ Assert.Equal(manifest.ManifestId, roundTripped!.ManifestId);
+ Assert.Equal(manifest.SchemaHash, roundTripped.SchemaHash);
+ Assert.Equal(manifest.Steps.Count, roundTripped.Steps.Count);
+
+ // For each step's row dump, confirm we have everything a restore needs to replay.
+ foreach (var step in roundTripped.Steps)
+ {
+ foreach (var row in step.Rows)
+ {
+ Assert.False(string.IsNullOrEmpty(row.Pk));
+ Assert.False(string.IsNullOrEmpty(row.Rk));
+ Assert.NotNull(row.Props);
+ }
+ if (step.Class == DeletionStepClass.Aggregate)
+ {
+ Assert.NotNull(step.Decrements);
+ foreach (var k in step.Decrements!)
+ {
+ Assert.False(string.IsNullOrEmpty(k.Vendor));
+ Assert.False(string.IsNullOrEmpty(k.Name));
+ }
+ }
+ }
+
+ // Spot-check: the Marker we seeded into Events survives the round-trip end-to-end.
+ var eventsStep = roundTripped.Steps.Single(s => s.Table == Constants.TableNames.Events);
+ Assert.Equal(2, eventsStep.Rows.Count);
+ Assert.Equal(DeletionPropEdmType.String, eventsStep.Rows[0].Props["Marker"].EdmType);
+ Assert.Equal("events_check", eventsStep.Rows[0].Props["Marker"].Value.GetString());
+ }
+
+ [Fact]
+ public async Task Build_preserves_per_column_edm_types_for_restore()
+ {
+ // Restore-faithfulness: every Azure Tables type Azure SDK exposes must surface in the dump
+ // with the correct EdmType tag so a future restore can re-Insert with the same column type
+ // instead of collapsing everything to plain strings.
+ var richRow = new TableEntity(TenantId, "rich")
+ {
+ ["StringProp"] = "hello",
+ ["IntProp"] = 42,
+ ["LongProp"] = 123456789012L,
+ ["DoubleProp"] = 3.14,
+ ["BoolProp"] = true,
+ ["DateProp"] = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc),
+ ["GuidProp"] = new Guid("33333333-3333-3333-3333-333333333333"),
+ ["BinaryProp"] = new byte[] { 0x01, 0x02, 0x03 },
+ };
+ richRow.ETag = new ETag("0xRICH");
+
+ var reader = new Mock();
+ reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny()))
+ .ReturnsAsync(richRow);
+ reader.Setup(r => r.GetSessionsIndexRowAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((TableEntity?)null);
+ reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((TableEntity?)null);
+ reader.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(EmptyAsyncEnumerable());
+
+ var manifest = await NewBuilder(reader.Object).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ // The rich row was placed on the Sessions row → it ends up in the FINAL tombstone step.
+ var tombstone = manifest.Steps.Last();
+ Assert.Equal(DeletionStepClass.Final, tombstone.Class);
+ var dump = tombstone.Rows[0];
+
+ Assert.Equal(DeletionPropEdmType.String, dump.Props["StringProp"].EdmType);
+ Assert.Equal(DeletionPropEdmType.Int32, dump.Props["IntProp"].EdmType);
+ Assert.Equal(DeletionPropEdmType.Int64, dump.Props["LongProp"].EdmType);
+ Assert.Equal(DeletionPropEdmType.Double, dump.Props["DoubleProp"].EdmType);
+ Assert.Equal(DeletionPropEdmType.Boolean, dump.Props["BoolProp"].EdmType);
+ Assert.Equal(DeletionPropEdmType.DateTime, dump.Props["DateProp"].EdmType);
+ Assert.Equal(DeletionPropEdmType.Guid, dump.Props["GuidProp"].EdmType);
+ Assert.Equal(DeletionPropEdmType.Binary, dump.Props["BinaryProp"].EdmType);
+
+ // Values round-trip through System.Text.Json with the encoding the restore expects.
+ Assert.Equal("hello", dump.Props["StringProp"].Value.GetString());
+ Assert.Equal(42, dump.Props["IntProp"].Value.GetInt32());
+ Assert.Equal(123456789012L, dump.Props["LongProp"].Value.GetInt64());
+ Assert.Equal(3.14, dump.Props["DoubleProp"].Value.GetDouble(), 5);
+ Assert.True(dump.Props["BoolProp"].Value.GetBoolean());
+
+ // DateTime → ISO-8601 string; restore parses with DateTime.Parse(round-trip kind).
+ var dateStr = dump.Props["DateProp"].Value.GetString();
+ Assert.NotNull(dateStr);
+ var parsed = DateTime.Parse(dateStr!, System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.RoundtripKind);
+ Assert.Equal(new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc), parsed.ToUniversalTime());
+
+ // Guid → string "33333333-3333-3333-3333-333333333333".
+ Assert.Equal("33333333-3333-3333-3333-333333333333", dump.Props["GuidProp"].Value.GetString());
+
+ // Binary → Base64 of the original bytes.
+ var b64 = dump.Props["BinaryProp"].Value.GetString();
+ Assert.Equal(Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 }), b64);
+ }
+
+ [Fact]
+ public async Task Build_falls_back_to_sessionsIndex_partition_scan_when_indexrowkey_missing()
+ {
+ // Edge: Sessions row exists but its IndexRowKey property is empty / corrupted. Without a
+ // fallback the cascade would leave a SessionsIndex orphan after tombstone. The builder
+ // must scan SessionsIndex by (PartitionKey, SessionId) to find the row anyway.
+ var sessionRow = new TableEntity(TenantId, SessionId)
+ {
+ ["IndexRowKey"] = string.Empty,
+ ["DiagnosticsBlobName"] = string.Empty,
+ ["Status"] = "Succeeded",
+ };
+ sessionRow.ETag = new ETag("0xSESS");
+
+ var orphanedIndexRow = new TableEntity(TenantId, "FALLBACK_" + SessionId)
+ {
+ ["SessionId"] = SessionId,
+ ["Status"] = "Succeeded",
+ };
+ orphanedIndexRow.ETag = new ETag("0xIDX");
+
+ var reader = new Mock();
+ reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny()))
+ .ReturnsAsync(sessionRow);
+ // Direct lookup with empty key intentionally NOT set up — builder must skip it and scan instead.
+ reader.Setup(r => r.GetSessionsIndexRowAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((TableEntity?)null);
+ reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((TableEntity?)null);
+ reader.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((tableName, filter, _) =>
+ tableName == Constants.TableNames.SessionsIndex
+ && filter.Contains($"SessionId eq '{SessionId}'", StringComparison.Ordinal)
+ ? AsyncFrom(new[] { orphanedIndexRow })
+ : EmptyAsyncEnumerable());
+
+ var manifest = await NewBuilder(reader.Object).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ var tombstone = manifest.Steps.Last();
+ Assert.Equal(DeletionStepClass.Final, tombstone.Class);
+ Assert.Equal(2, tombstone.Rows.Count);
+ // SessionsIndex first (recovered via fallback scan), then Sessions.
+ Assert.Equal("FALLBACK_" + SessionId, tombstone.Rows[0].Rk);
+ Assert.Equal(SessionId, tombstone.Rows[1].Rk);
+ }
+
+ [Fact]
+ public async Task Build_falls_back_to_sessionsIndex_partition_scan_when_direct_lookup_returns_null()
+ {
+ // Edge: Sessions.IndexRowKey points at a SessionsIndex row that has already been deleted
+ // (or never existed). The direct lookup returns 404 → builder must still scan to catch any
+ // OTHER index row that happens to reference this SessionId. Defensive: handles the case
+ // where IndexRowKey drifted between Sessions write and cascade build.
+ const string staleIndexRowKey = "STALE_KEY";
+ var sessionRow = new TableEntity(TenantId, SessionId)
+ {
+ ["IndexRowKey"] = staleIndexRowKey,
+ ["Status"] = "Succeeded",
+ };
+ sessionRow.ETag = new ETag("0xSESS");
+
+ var recoveredIndexRow = new TableEntity(TenantId, "RECOVERED_" + SessionId)
+ {
+ ["SessionId"] = SessionId,
+ ["Status"] = "Succeeded",
+ };
+ recoveredIndexRow.ETag = new ETag("0xIDX");
+
+ var reader = new Mock();
+ reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny()))
+ .ReturnsAsync(sessionRow);
+ reader.Setup(r => r.GetSessionsIndexRowAsync(TenantId, staleIndexRowKey, It.IsAny()))
+ .ReturnsAsync((TableEntity?)null); // stale key → 404
+ reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((TableEntity?)null);
+ reader.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((tableName, filter, _) =>
+ tableName == Constants.TableNames.SessionsIndex
+ && filter.Contains($"SessionId eq '{SessionId}'", StringComparison.Ordinal)
+ ? AsyncFrom(new[] { recoveredIndexRow })
+ : EmptyAsyncEnumerable());
+
+ var manifest = await NewBuilder(reader.Object).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ var tombstone = manifest.Steps.Last();
+ Assert.Equal(2, tombstone.Rows.Count);
+ Assert.Equal("RECOVERED_" + SessionId, tombstone.Rows[0].Rk);
+ }
+
+ [Fact]
+ public async Task Build_handles_missing_sessions_row_without_throwing()
+ {
+ // Edge: cascade preflight fired against an already-deleted session. Builder must not throw;
+ // the tombstone step just carries no rows.
+ var reader = new Mock();
+ reader.Setup(r => r.GetSessionRowAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((TableEntity?)null);
+ reader.Setup(r => r.GetSessionsIndexRowAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((TableEntity?)null);
+ reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((TableEntity?)null);
+ reader.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(EmptyAsyncEnumerable());
+
+ var manifest = await NewBuilder(reader.Object).BuildAsync(
+ TenantId, SessionId, "admin_delete",
+ new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ new DeletionRetentionContext { TenantRetentionDays = 90 });
+
+ var tombstone = manifest.Steps.Last();
+ Assert.Equal(DeletionStepClass.Final, tombstone.Class);
+ Assert.Equal(0, tombstone.RowCount);
+ Assert.Empty(tombstone.Rows);
+ }
+
+ // ---------------------------------------------------------------- Test-fixture plumbing ----
+
+ private static DeletionManifestBuilder NewBuilder(ISessionDeletionInventoryReader reader)
+ => new DeletionManifestBuilder(reader, NullLogger.Instance);
+
+ private static ISessionDeletionInventoryReader NewReader(Action seedAction)
+ {
+ var seed = new ReaderSeed();
+ seedAction(seed);
+ return seed.Build();
+ }
+
+ private static TableEntity MakeContributionsRow(List keys, bool compressed)
+ {
+ var json = JsonSerializer.Serialize(keys, DeletionManifestJson.SerializerOptions);
+ string stored;
+ if (compressed)
+ {
+ using var output = new MemoryStream();
+ using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
+ {
+ var bytes = Encoding.UTF8.GetBytes(json);
+ gzip.Write(bytes, 0, bytes.Length);
+ }
+ stored = Convert.ToBase64String(output.ToArray());
+ }
+ else
+ {
+ stored = json;
+ }
+
+ return new TableEntity(TenantId, SessionId)
+ {
+ ["SoftwareKeysJson"] = stored,
+ ["IsCompressed"] = compressed,
+ ["CountedAt"] = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc),
+ ["LastUpdatedAt"] = new DateTime(2026, 5, 12, 9, 0, 0, DateTimeKind.Utc),
+ ["KeyCount"] = keys.Count,
+ };
+ }
+
+ private static async IAsyncEnumerable EmptyAsyncEnumerable()
+ {
+ await Task.CompletedTask;
+ yield break;
+ }
+
+ private static async IAsyncEnumerable AsyncFrom(IEnumerable source)
+ {
+ foreach (var entity in source)
+ {
+ yield return entity;
+ }
+ await Task.CompletedTask;
+ }
+
+ ///
+ /// Fluent test-data seed. Every WithXxx registers Moq setups for the corresponding
+ /// table; tables not seeded return empty / null on the reader.
+ ///
+ private class ReaderSeed
+ {
+ private readonly Dictionary> _query = new(StringComparer.Ordinal);
+ private TableEntity? _sessionsRow;
+ private TableEntity? _sessionsIndexRow;
+ private TableEntity? _vulnerabilityReport;
+ private TableEntity? _deviceSnapshot;
+ private TableEntity? _eventSessionIndex;
+ private TableEntity? _sessionInventoryContributions;
+ private string _sessionsIndexRowKey = "INDEX_" + SessionId;
+
+ public ReaderSeed WithSessionsRow(string? diagnosticsBlobName = null)
+ {
+ _sessionsRow = new TableEntity(TenantId, SessionId)
+ {
+ ["IndexRowKey"] = _sessionsIndexRowKey,
+ ["DiagnosticsBlobName"] = diagnosticsBlobName ?? string.Empty,
+ ["Status"] = "Succeeded",
+ };
+ // Mark with a stable ETag so dumps look real.
+ _sessionsRow.ETag = new ETag("0xSESS");
+ return this;
+ }
+
+ public ReaderSeed WithSessionsIndexRow()
+ {
+ _sessionsIndexRow = new TableEntity(TenantId, _sessionsIndexRowKey)
+ {
+ ["SessionId"] = SessionId,
+ ["Status"] = "Succeeded",
+ };
+ _sessionsIndexRow.ETag = new ETag("0xIDX");
+ return this;
+ }
+
+ public ReaderSeed WithEvents(int n, string propValue = "evt") => SeedSessionPartition(Constants.TableNames.Events, n, propValue);
+ public ReaderSeed WithRuleResults(int n, string propValue = "rule") => SeedSessionPartition(Constants.TableNames.RuleResults, n, propValue);
+ public ReaderSeed WithSignals(int n, string propValue = "sig") => SeedSessionPartition(Constants.TableNames.Signals, n, propValue);
+ public ReaderSeed WithDecisionTransitions(int n, string propValue = "tx") => SeedSessionPartition(Constants.TableNames.DecisionTransitions, n, propValue);
+
+ public ReaderSeed WithAppInstallSummaries(int n, string propValue = "app")
+ {
+ var rows = new List(n);
+ for (int i = 0; i < n; i++)
+ {
+ var e = new TableEntity(TenantId, $"app{i}")
+ {
+ ["SessionId"] = SessionId,
+ ["Marker"] = propValue,
+ };
+ e.ETag = new ETag("0xAPP" + i);
+ rows.Add(e);
+ }
+ _query[Constants.TableNames.AppInstallSummaries] = rows;
+ return this;
+ }
+
+ public ReaderSeed WithVulnerabilityReport(string propValue = "vuln")
+ {
+ _vulnerabilityReport = new TableEntity($"{TenantId}_{SessionId}", "report")
+ {
+ ["Marker"] = propValue,
+ };
+ _vulnerabilityReport.ETag = new ETag("0xVULN");
+ return this;
+ }
+
+ public ReaderSeed WithDeviceSnapshot(string propValue = "dev")
+ {
+ _deviceSnapshot = new TableEntity(TenantId, SessionId)
+ {
+ ["Marker"] = propValue,
+ };
+ _deviceSnapshot.ETag = new ETag("0xDEV");
+ return this;
+ }
+
+ public ReaderSeed WithEventSessionIndex(string propValue = "esi")
+ {
+ _eventSessionIndex = new TableEntity(TenantId, SessionId)
+ {
+ ["Marker"] = propValue,
+ };
+ _eventSessionIndex.ETag = new ETag("0xESI");
+ return this;
+ }
+
+ public ReaderSeed WithEventTypeIndex(int n, string propValue = "eti")
+ {
+ // PK = {tenantId}_{eventType}, RK ends with _{sessionId}.
+ var rows = new List(n);
+ for (int i = 0; i < n; i++)
+ {
+ var e = new TableEntity($"{TenantId}_evt{i}", $"00000000000000{i:D5}_{SessionId}")
+ {
+ ["Marker"] = propValue,
+ ["SessionId"] = SessionId,
+ };
+ e.ETag = new ETag("0xETI" + i);
+ rows.Add(e);
+ }
+ _query[Constants.TableNames.EventTypeIndex] = rows;
+ return this;
+ }
+
+ public ReaderSeed WithCveIndex(int n, string propValue = "cve")
+ {
+ // PK = {tenantId}_{cveId}, RK = sessionId.
+ var rows = new List(n);
+ for (int i = 0; i < n; i++)
+ {
+ var e = new TableEntity($"{TenantId}_CVE-2026-{i:D4}", SessionId)
+ {
+ ["Marker"] = propValue,
+ ["SessionId"] = SessionId,
+ };
+ e.ETag = new ETag("0xCVE" + i);
+ rows.Add(e);
+ }
+ _query[Constants.TableNames.CveIndex] = rows;
+ return this;
+ }
+
+ public ReaderSeed WithSessionsByTerminal(int n, string propValue = "term") => SeedDiscriminatorPkProp(Constants.TableNames.SessionsByTerminal, n, propValue, "Failed");
+ public ReaderSeed WithSessionsByStage(int n, string propValue = "stg") => SeedDiscriminatorPkProp(Constants.TableNames.SessionsByStage, n, propValue, "AccountSetup");
+ public ReaderSeed WithDeadEndsByReason(int n, string propValue = "de") => SeedDiscriminatorPkProp(Constants.TableNames.DeadEndsByReason, n, propValue, "Timeout");
+ public ReaderSeed WithClassifierVerdictsByIdLevel(int n, string propValue = "cls") => SeedDiscriminatorPkProp(Constants.TableNames.ClassifierVerdictsByIdLevel, n, propValue, "EspApps_High");
+ public ReaderSeed WithSignalsByKind(int n, string propValue = "sk") => SeedDiscriminatorPkProp(Constants.TableNames.SignalsByKind, n, propValue, "EspPhaseChanged");
+
+ public ReaderSeed WithSessionInventoryContributions(TableEntity row)
+ {
+ _sessionInventoryContributions = row;
+ _sessionInventoryContributions.ETag = new ETag("0xCONTRIB");
+ return this;
+ }
+
+ public ISessionDeletionInventoryReader Build()
+ {
+ var reader = new Mock();
+
+ reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny()))
+ .ReturnsAsync(_sessionsRow);
+ reader.Setup(r => r.GetSessionsIndexRowAsync(TenantId, _sessionsIndexRowKey, It.IsAny()))
+ .ReturnsAsync(_sessionsIndexRow);
+
+ // PK_RK_EXACT lookups
+ reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.VulnerabilityReports, It.IsAny(), "report", It.IsAny()))
+ .ReturnsAsync(_vulnerabilityReport);
+ reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.DeviceSnapshot, TenantId, SessionId, It.IsAny()))
+ .ReturnsAsync(_deviceSnapshot);
+ reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.EventSessionIndex, TenantId, SessionId, It.IsAny()))
+ .ReturnsAsync(_eventSessionIndex);
+ reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny()))
+ .ReturnsAsync(_sessionInventoryContributions);
+ // Default null for any other GetEntityOrNullAsync.
+ reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((TableEntity?)null);
+ // Re-register the specific ones AFTER the default catch-all (Moq honors the most recent setup).
+ reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.VulnerabilityReports, It.IsAny(), "report", It.IsAny()))
+ .ReturnsAsync(_vulnerabilityReport);
+ reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.DeviceSnapshot, TenantId, SessionId, It.IsAny()))
+ .ReturnsAsync(_deviceSnapshot);
+ reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.EventSessionIndex, TenantId, SessionId, It.IsAny()))
+ .ReturnsAsync(_eventSessionIndex);
+ reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny()))
+ .ReturnsAsync(_sessionInventoryContributions);
+
+ // QueryAsync: per-table seeded list, otherwise empty.
+ reader.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((tableName, _, _) =>
+ _query.TryGetValue(tableName, out var rows)
+ ? AsyncFrom(rows)
+ : EmptyAsyncEnumerable());
+
+ return reader.Object;
+ }
+
+ private ReaderSeed SeedSessionPartition(string tableName, int n, string propValue)
+ {
+ var partitionKey = $"{TenantId}_{SessionId}";
+ var rows = new List(n);
+ for (int i = 0; i < n; i++)
+ {
+ var e = new TableEntity(partitionKey, $"{tableName}_{i:D5}")
+ {
+ ["Marker"] = propValue,
+ ["SessionId"] = SessionId,
+ };
+ e.ETag = new ETag("0x" + tableName + i);
+ rows.Add(e);
+ }
+ _query[tableName] = rows;
+ return this;
+ }
+
+ private ReaderSeed SeedDiscriminatorPkProp(string tableName, int n, string propValue, string discriminator)
+ {
+ // PK = {tenantId}_{discriminator}, server-side filter on PK prefix + SessionId == sessionId.
+ var rows = new List(n);
+ for (int i = 0; i < n; i++)
+ {
+ var e = new TableEntity($"{TenantId}_{discriminator}", $"{tableName}_{i:D5}")
+ {
+ ["Marker"] = propValue,
+ ["SessionId"] = SessionId,
+ };
+ e.ETag = new ETag("0x" + tableName + i);
+ rows.Add(e);
+ }
+ _query[tableName] = rows;
+ return this;
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestSerializationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestSerializationTests.cs
new file mode 100644
index 00000000..cb96f1dd
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestSerializationTests.cs
@@ -0,0 +1,187 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using AutopilotMonitor.Shared.Models.Deletion;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Pure JSON serialization round-trip tests. The cascade plan §3 schema is the wire contract;
+/// both producer + worker depend on byte-faithful round-trip of the manifest types.
+///
+public class DeletionManifestSerializationTests
+{
+ [Fact]
+ public void Manifest_round_trips_through_system_text_json_with_camel_case()
+ {
+ var original = BuildSampleManifest();
+
+ var json = JsonSerializer.Serialize(original, DeletionManifestJson.SerializerOptions);
+
+ // Camel-case casing per plan §3 (top-level fields).
+ Assert.Contains("\"manifestId\":", json);
+ Assert.Contains("\"tenantId\":", json);
+ Assert.Contains("\"preflightCounts\":", json);
+ Assert.Contains("\"steps\":", json);
+ Assert.Contains("\"schemaHash\":", json);
+ // PascalCase shouldn't leak.
+ Assert.DoesNotContain("\"ManifestId\":", json);
+ Assert.DoesNotContain("\"Steps\":", json);
+
+ var decoded = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions);
+
+ Assert.NotNull(decoded);
+ Assert.Equal(original.ManifestId, decoded!.ManifestId);
+ Assert.Equal(original.TenantId, decoded.TenantId);
+ Assert.Equal(original.SessionId, decoded.SessionId);
+ Assert.Equal(original.Reason, decoded.Reason);
+ Assert.Equal(original.SchemaHash, decoded.SchemaHash);
+ Assert.Equal(original.PreflightCounts.Count, decoded.PreflightCounts.Count);
+ Assert.Equal(original.Steps.Count, decoded.Steps.Count);
+ }
+
+ [Fact]
+ public void DeletionStep_AGGREGATE_carries_decrements_but_no_rows()
+ {
+ var step = new DeletionStep
+ {
+ Order = 16,
+ Step = DeletionStepNames.SoftwareInventoryDecrement,
+ Class = DeletionStepClass.Aggregate,
+ RowCount = 2,
+ Rows = new List(),
+ Decrements = new List
+ {
+ new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" },
+ new DeletionDecrementKey { Vendor = "Fabrikam", Name = "Gadget", Version = "2.0" },
+ },
+ };
+
+ var json = JsonSerializer.Serialize(step, DeletionManifestJson.SerializerOptions);
+ var decoded = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions);
+
+ Assert.NotNull(decoded);
+ Assert.Equal(DeletionStepClass.Aggregate, decoded!.Class);
+ Assert.Empty(decoded.Rows);
+ Assert.NotNull(decoded.Decrements);
+ Assert.Equal(2, decoded.Decrements!.Count);
+ Assert.Equal("Contoso", decoded.Decrements[0].Vendor);
+ Assert.Equal("Widget", decoded.Decrements[0].Name);
+ // Plan §3: AGGREGATE steps have decrements but no Rows entries.
+ Assert.True(decoded.Rows.Count == 0);
+ }
+
+ [Fact]
+ public void DeletionStep_FINAL_carries_two_rows_in_order()
+ {
+ // Plan §5 PR4: tombstone deletes SessionsIndex FIRST, then Sessions.
+ var step = new DeletionStep
+ {
+ Order = 18,
+ Step = DeletionStepNames.Tombstone,
+ Class = DeletionStepClass.Final,
+ RowCount = 2,
+ Rows = new List
+ {
+ new DeletionRowDump { Pk = "tenant", Rk = "indexRowKey", Etag = "0x1" },
+ new DeletionRowDump { Pk = "tenant", Rk = "sessionId", Etag = "0x2" },
+ },
+ };
+
+ var json = JsonSerializer.Serialize(step, DeletionManifestJson.SerializerOptions);
+ var decoded = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions);
+
+ Assert.NotNull(decoded);
+ Assert.Equal(DeletionStepClass.Final, decoded!.Class);
+ Assert.Equal(2, decoded.Rows.Count);
+ Assert.Equal("indexRowKey", decoded.Rows[0].Rk);
+ Assert.Equal("sessionId", decoded.Rows[1].Rk);
+ }
+
+ [Fact]
+ public void DeletionRowDump_typed_props_round_trip()
+ {
+ var props = new Dictionary(StringComparer.Ordinal)
+ {
+ ["StringProp"] = new DeletionPropValue { EdmType = DeletionPropEdmType.String, Value = ToElement("\"hello\"") },
+ ["IntProp"] = new DeletionPropValue { EdmType = DeletionPropEdmType.Int32, Value = ToElement("42") },
+ ["BoolProp"] = new DeletionPropValue { EdmType = DeletionPropEdmType.Boolean, Value = ToElement("true") },
+ ["NumberProp"] = new DeletionPropValue { EdmType = DeletionPropEdmType.Double, Value = ToElement("3.14") },
+ };
+ var dump = new DeletionRowDump { Pk = "pk", Rk = "rk", Etag = "0x1", Props = props };
+
+ var json = JsonSerializer.Serialize(dump, DeletionManifestJson.SerializerOptions);
+ var decoded = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions);
+
+ Assert.NotNull(decoded);
+ Assert.Equal(DeletionPropEdmType.String, decoded!.Props["StringProp"].EdmType);
+ Assert.Equal(DeletionPropEdmType.Int32, decoded.Props["IntProp"].EdmType);
+ Assert.Equal(DeletionPropEdmType.Boolean, decoded.Props["BoolProp"].EdmType);
+ Assert.Equal(DeletionPropEdmType.Double, decoded.Props["NumberProp"].EdmType);
+
+ Assert.Equal("hello", decoded.Props["StringProp"].Value.GetString());
+ Assert.Equal(42, decoded.Props["IntProp"].Value.GetInt32());
+ Assert.True(decoded.Props["BoolProp"].Value.GetBoolean());
+ Assert.Equal(3.14, decoded.Props["NumberProp"].Value.GetDouble(), 5);
+ }
+
+ [Fact]
+ public void DeletionProgress_round_trips_under_500_bytes()
+ {
+ // Plan §3 R9: progress blob is intentionally minimal so an operator can open it during incident response.
+ var progress = new DeletionProgress
+ {
+ SnapshotSha256 = new string('a', 64),
+ CompletedSteps = new HashSet { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17 },
+ VerificationDone = true,
+ CompletedAt = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc),
+ };
+
+ var json = JsonSerializer.SerializeToUtf8Bytes(progress, DeletionManifestJson.SerializerOptions);
+
+ Assert.True(json.Length < 500, $"Progress blob serialized to {json.Length} bytes — expected <500 per §3 R9.");
+
+ var decoded = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions);
+ Assert.NotNull(decoded);
+ Assert.Equal(progress.SnapshotSha256, decoded!.SnapshotSha256);
+ Assert.Equal(progress.CompletedSteps, decoded.CompletedSteps);
+ Assert.True(decoded.VerificationDone);
+ Assert.Equal(progress.CompletedAt, decoded.CompletedAt);
+ }
+
+ private static DeletionManifest BuildSampleManifest() => new DeletionManifest
+ {
+ ManifestId = "0123456789ABCDEF_FEDCBA9876543210",
+ ManifestVersion = 1,
+ TenantId = "11111111-1111-1111-1111-111111111111",
+ SessionId = "22222222-2222-2222-2222-222222222222",
+ CreatedAt = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc),
+ CreatedBy = new DeletionActor { Type = "admin", Actor = "alice@example.com" },
+ Reason = "admin_delete",
+ RetentionContext = new DeletionRetentionContext { TenantRetentionDays = 90 },
+ PreflightCounts = new Dictionary { ["events"] = 5, ["ruleResults"] = 1 },
+ DiagnosticsBlobName = "diag.zip",
+ Steps = new List
+ {
+ new DeletionStep
+ {
+ Order = 1,
+ Table = "Events",
+ Class = DeletionStepClass.PkBySession,
+ RowCount = 1,
+ Rows = new List
+ {
+ new DeletionRowDump { Pk = "t_s", Rk = "evt", Etag = "0x1" },
+ },
+ },
+ },
+ SchemaHash = "sha256:abc",
+ };
+
+ private static JsonElement ToElement(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs
index 32b6224f..f294ee7b 100644
--- a/src/Backend/AutopilotMonitor.Functions/Program.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Program.cs
@@ -126,6 +126,11 @@ builder.Services.AddMemoryCache();
builder.Services.AddHttpClient();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+// Cascade-deletion read surface implemented by the TableStorageService partial (PR1).
+// No production caller in PR1; producer (PR3) + worker (PR4) consume it later.
+builder.Services.AddSingleton(
+ sp => sp.GetRequiredService());
+builder.Services.AddSingleton();
builder.Services.AddHostedService(); // Initialize all tables at startup
// Data Access Layer — repository interfaces backed by Table Storage.
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs
index f0f5fd1b..baa0232d 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs
@@ -1,8 +1,13 @@
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text.Json;
+using Azure;
using Azure.Identity;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Blobs.Specialized;
using Azure.Storage.Sas;
+using AutopilotMonitor.Shared.Models.Deletion;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
@@ -15,6 +20,10 @@ namespace AutopilotMonitor.Functions.Services
///
public class BlobStorageService
{
+ // Container that holds cascade-deletion manifests (snapshot + progress blobs).
+ // Plan §3 + §10: 30d Lifecycle delete + 3d soft-delete = effective max 33-day retention.
+ private const string DeletionManifestsContainer = "deletion-manifests";
+
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger _logger;
private readonly bool _usesManagedIdentity;
@@ -46,6 +55,17 @@ namespace AutopilotMonitor.Functions.Services
}
}
+ ///
+ /// Test seam: construct directly from a (possibly Moq'd) .
+ /// Used by xUnit so manifest upload/download paths can be exercised without hitting Azure.
+ ///
+ internal BlobStorageService(BlobServiceClient blobServiceClient, ILogger logger, bool usesManagedIdentity = false)
+ {
+ _blobServiceClient = blobServiceClient;
+ _logger = logger;
+ _usesManagedIdentity = usesManagedIdentity;
+ }
+
///
/// Gets a BlobContainerClient for the specified container.
///
@@ -93,5 +113,176 @@ namespace AutopilotMonitor.Functions.Services
return blobClient.Uri.ToString();
}
}
+
+ // ============================================================ Cascade-deletion manifests ====
+
+ ///
+ /// Uploads the immutable snapshot blob for a cascade-deletion manifest. Path:
+ /// {tenantId}/{sessionId}/{manifestId}.snapshot.json.gz. Gzipped on the wire,
+ /// SHA-256 of the uncompressed payload pinned to the blob's sha256 metadata.
+ /// Uses If-None-Match=* so a duplicate-id upload fails loud — the snapshot is
+ /// supposed to be written exactly once per producer attempt (plan §1 P1).
+ ///
+ public async Task UploadDeletionManifestAsync(
+ DeletionManifest manifest, CancellationToken cancellationToken = default)
+ {
+ if (manifest == null) throw new ArgumentNullException(nameof(manifest));
+ if (string.IsNullOrEmpty(manifest.ManifestId)) throw new ArgumentException("Manifest.ManifestId is required", nameof(manifest));
+ if (string.IsNullOrEmpty(manifest.TenantId)) throw new ArgumentException("Manifest.TenantId is required", nameof(manifest));
+ if (string.IsNullOrEmpty(manifest.SessionId)) throw new ArgumentException("Manifest.SessionId is required", nameof(manifest));
+
+ var uncompressed = JsonSerializer.SerializeToUtf8Bytes(manifest, DeletionManifestJson.SerializerOptions);
+
+ string sha256Hex;
+ using (var sha = SHA256.Create())
+ {
+ sha256Hex = Convert.ToHexString(sha.ComputeHash(uncompressed)).ToLowerInvariant();
+ }
+
+ byte[] gzipped;
+ using (var output = new MemoryStream())
+ {
+ using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
+ {
+ await gzip.WriteAsync(uncompressed, 0, uncompressed.Length, cancellationToken);
+ }
+ gzipped = output.ToArray();
+ }
+
+ var blobName = BuildSnapshotBlobName(manifest.TenantId, manifest.SessionId, manifest.ManifestId);
+ var options = new BlobUploadOptions
+ {
+ HttpHeaders = new BlobHttpHeaders
+ {
+ ContentType = "application/json",
+ ContentEncoding = "gzip",
+ },
+ Metadata = new Dictionary(StringComparer.Ordinal)
+ {
+ ["sha256"] = sha256Hex,
+ },
+ Conditions = new BlobRequestConditions
+ {
+ IfNoneMatch = ETag.All,
+ },
+ };
+
+ await WriteDeletionManifestBlobAsync(blobName, gzipped, options, cancellationToken);
+
+ _logger.LogInformation(
+ "Uploaded deletion manifest snapshot blob={Blob} sha256={Sha} sizeBytes={Size}",
+ blobName, sha256Hex, gzipped.Length);
+
+ return new DeletionManifestBlobPointer
+ {
+ ContainerName = DeletionManifestsContainer,
+ BlobName = blobName,
+ SnapshotSha256 = sha256Hex,
+ SizeBytes = gzipped.Length,
+ };
+ }
+
+ ///
+ /// Downloads the immutable snapshot blob, gunzips it, deserializes the manifest, and
+ /// verifies the uncompressed-payload SHA-256 against the blob's sha256 metadata.
+ /// Throws on hash mismatch (corruption signal — plan §13
+ /// restore semantics rely on this); rethrows the underlying
+ /// on 404 or other storage error.
+ ///
+ public async Task DownloadDeletionManifestAsync(
+ string tenantId, string sessionId, string manifestId, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("tenantId is required", nameof(tenantId));
+ if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("sessionId is required", nameof(sessionId));
+ if (string.IsNullOrEmpty(manifestId)) throw new ArgumentException("manifestId is required", nameof(manifestId));
+
+ var blobName = BuildSnapshotBlobName(tenantId, sessionId, manifestId);
+ var (gzipped, metadata) = await ReadDeletionManifestBlobAsync(blobName, cancellationToken);
+
+ byte[] uncompressed;
+ using (var input = new MemoryStream(gzipped, writable: false))
+ using (var gunzip = new GZipStream(input, CompressionMode.Decompress))
+ using (var output = new MemoryStream())
+ {
+ await gunzip.CopyToAsync(output, cancellationToken);
+ uncompressed = output.ToArray();
+ }
+
+ string actualSha256;
+ using (var sha = SHA256.Create())
+ {
+ actualSha256 = Convert.ToHexString(sha.ComputeHash(uncompressed)).ToLowerInvariant();
+ }
+
+ string? expectedSha256 = null;
+ if (metadata != null && metadata.TryGetValue("sha256", out var meta))
+ {
+ expectedSha256 = meta;
+ }
+
+ if (string.IsNullOrEmpty(expectedSha256))
+ {
+ throw new InvalidDataException(
+ $"Deletion manifest blob {blobName} is missing the required 'sha256' metadata; refusing to use it (corruption signal).");
+ }
+ if (!string.Equals(expectedSha256, actualSha256, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException(
+ $"Deletion manifest blob {blobName} SHA-256 mismatch: expected {expectedSha256}, got {actualSha256} (corruption signal).");
+ }
+
+ var manifest = JsonSerializer.Deserialize(uncompressed, DeletionManifestJson.SerializerOptions);
+ if (manifest == null)
+ {
+ throw new InvalidDataException($"Deletion manifest blob {blobName} deserialized to null.");
+ }
+ return manifest;
+ }
+
+ ///
+ /// Test seam: writes the gzipped manifest blob with the supplied upload options. The
+ /// production override calls Azure Blob Storage; tests subclass this method to capture
+ /// the bytes + options without spinning up Azurite or wrestling with BlobsModelFactory.
+ ///
+ protected internal virtual async Task WriteDeletionManifestBlobAsync(
+ string blobName, byte[] gzipped, BlobUploadOptions options, CancellationToken cancellationToken)
+ {
+ var containerClient = _blobServiceClient.GetBlobContainerClient(DeletionManifestsContainer);
+ await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
+ var blobClient = containerClient.GetBlobClient(blobName);
+ using var ms = new MemoryStream(gzipped, writable: false);
+ await blobClient.UploadAsync(ms, options, cancellationToken);
+ }
+
+ ///
+ /// Test seam: reads the gzipped manifest blob and its metadata. Production override
+ /// calls Azure Blob Storage; tests subclass this method to return canned bytes.
+ ///
+ protected internal virtual async Task<(byte[] Gzipped, IDictionary? Metadata)>
+ ReadDeletionManifestBlobAsync(string blobName, CancellationToken cancellationToken)
+ {
+ var containerClient = _blobServiceClient.GetBlobContainerClient(DeletionManifestsContainer);
+ var blobClient = containerClient.GetBlobClient(blobName);
+ using var downloaded = new MemoryStream();
+ await blobClient.DownloadToAsync(downloaded, cancellationToken);
+ var properties = await blobClient.GetPropertiesAsync(cancellationToken: cancellationToken);
+ return (downloaded.ToArray(), properties.Value?.Metadata);
+ }
+
+ private static string BuildSnapshotBlobName(string tenantId, string sessionId, string manifestId)
+ => $"{tenantId}/{sessionId}/{manifestId}.snapshot.json.gz";
+ }
+
+ ///
+ /// Pointer record returned by .
+ /// Producer (PR3) persists this in the audit log + uses the SHA to bind the snapshot to its
+ /// progress blob.
+ ///
+ public class DeletionManifestBlobPointer
+ {
+ public string ContainerName { get; set; } = string.Empty;
+ public string BlobName { get; set; } = string.Empty;
+ public string SnapshotSha256 { get; set; } = string.Empty;
+ public long SizeBytes { get; set; }
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs
new file mode 100644
index 00000000..91cf8aa5
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs
@@ -0,0 +1,536 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.Data.Tables;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models.Deletion;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Services.Deletion
+{
+ ///
+ /// Read-only enumerator that produces a snapshot of every
+ /// row a cascade-delete would target for a given (tenant, session). PR1 ships only the
+ /// builder; the producer + worker that actually use the manifest land in PR3 / PR4.
+ ///
+ /// Per plan §1 P1 / §3: the manifest IS the backup. A row not captured here will not be
+ /// deleted; a row captured here can be restored byte-faithful from the JSON dump. The
+ /// builder never deletes, never CAS-locks, never uploads — it only reads.
+ ///
+ ///
+ public class DeletionManifestBuilder
+ {
+ private readonly ISessionDeletionInventoryReader _reader;
+ private readonly ILogger _logger;
+
+ // Azure Tables system properties that should not appear in the per-row Props bag.
+ private static readonly HashSet SystemPropertyNames = new HashSet(StringComparer.Ordinal)
+ {
+ "PartitionKey", "RowKey", "Timestamp", "odata.etag",
+ };
+
+ // Per-row dump for the rare 0-row table is allowed; per-class step is always emitted.
+ private static readonly JsonSerializerOptions HashSerializerOptions = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false,
+ };
+
+ public DeletionManifestBuilder(ISessionDeletionInventoryReader reader, ILogger logger)
+ {
+ _reader = reader;
+ _logger = logger;
+ }
+
+ ///
+ /// Builds a complete cascade snapshot for the given session. Read-only; safe to call
+ /// from a preview endpoint. The returned manifest carries full row dumps suitable for
+ /// both forward execution (cascade) and reverse execution (restore).
+ ///
+ public async Task BuildAsync(
+ string tenantId,
+ string sessionId,
+ string reason,
+ DeletionActor actor,
+ DeletionRetentionContext retentionContext,
+ CancellationToken cancellationToken = default)
+ {
+ var manifest = new DeletionManifest
+ {
+ ManifestId = NewManifestId(),
+ ManifestVersion = 1,
+ TenantId = tenantId,
+ SessionId = sessionId,
+ CreatedAt = DateTime.UtcNow,
+ CreatedBy = actor,
+ Reason = reason,
+ RetentionContext = retentionContext,
+ };
+
+ // Step A — load the Sessions + SessionsIndex rows up front. Both are needed for the
+ // FINAL tombstone step; the Sessions row also gives us IndexRowKey + DiagnosticsBlobName.
+ var sessionRow = await _reader.GetSessionRowAsync(tenantId, sessionId, cancellationToken);
+ TableEntity? sessionsIndexRow = null;
+ if (sessionRow != null)
+ {
+ manifest.DiagnosticsBlobName = sessionRow.GetString("DiagnosticsBlobName");
+ sessionsIndexRow = await ResolveSessionsIndexRowAsync(tenantId, sessionId, sessionRow, cancellationToken);
+ }
+
+ var safeTenantId = ODataSanitizer.EscapeValue(tenantId);
+ var safeSessionId = ODataSanitizer.EscapeValue(sessionId);
+ var compositeSessionPk = $"{tenantId}_{sessionId}";
+
+ // ---- Steps 1-15: cascade tables, in the §3 order. ----
+ await AddPkBySessionStepAsync(manifest, order: 1, table: Constants.TableNames.Events, compositeSessionPk, safeTenantId, safeSessionId, cancellationToken);
+ await AddPkBySessionStepAsync(manifest, order: 2, table: Constants.TableNames.RuleResults, compositeSessionPk, safeTenantId, safeSessionId, cancellationToken);
+ await AddPropTenantPkStepAsync(manifest, order: 3, table: Constants.TableNames.AppInstallSummaries, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken);
+ await AddPkRkExactStepAsync(manifest, order: 4, table: Constants.TableNames.VulnerabilityReports, partitionKey: compositeSessionPk, rowKey: "report", cancellationToken);
+ await AddPkRkExactStepAsync(manifest, order: 5, table: Constants.TableNames.DeviceSnapshot, partitionKey: tenantId, rowKey: sessionId, cancellationToken);
+ await AddPkRkExactStepAsync(manifest, order: 6, table: Constants.TableNames.EventSessionIndex, partitionKey: tenantId, rowKey: sessionId, cancellationToken);
+ await AddPkBySessionStepAsync(manifest, order: 7, table: Constants.TableNames.Signals, compositeSessionPk, safeTenantId, safeSessionId, cancellationToken);
+ await AddPkBySessionStepAsync(manifest, order: 8, table: Constants.TableNames.DecisionTransitions, compositeSessionPk, safeTenantId, safeSessionId, cancellationToken);
+ await AddDiscriminatorPkRkSuffixStepAsync(manifest, order: 9, table: Constants.TableNames.EventTypeIndex, tenantId, sessionId, safeTenantId, cancellationToken);
+ await AddDiscriminatorPkRkExactStepAsync(manifest, order: 10, table: Constants.TableNames.CveIndex, tenantId, sessionId, safeTenantId, cancellationToken);
+ await AddDiscriminatorPkPropStepAsync(manifest, order: 11, table: Constants.TableNames.SessionsByTerminal, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken);
+ await AddDiscriminatorPkPropStepAsync(manifest, order: 12, table: Constants.TableNames.SessionsByStage, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken);
+ await AddDiscriminatorPkPropStepAsync(manifest, order: 13, table: Constants.TableNames.DeadEndsByReason, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken);
+ await AddDiscriminatorPkPropStepAsync(manifest, order: 14, table: Constants.TableNames.ClassifierVerdictsByIdLevel, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken);
+ await AddDiscriminatorPkPropStepAsync(manifest, order: 15, table: Constants.TableNames.SignalsByKind, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken);
+
+ // ---- Steps 16 + 17: SoftwareInventory side-row (omit both for pre-side-row sessions). ----
+ var contributionsRow = await _reader.GetEntityOrNullAsync(
+ Constants.TableNames.SessionInventoryContributions, tenantId, sessionId, cancellationToken);
+ if (contributionsRow != null)
+ {
+ AddSoftwareInventoryDecrementStep(manifest, order: 16, contributionsRow);
+ AddContributionsRowStep(manifest, order: 17, contributionsRow);
+ }
+
+ // ---- Step 18: Tombstone (SessionsIndex first, then Sessions). ----
+ AddTombstoneStep(manifest, order: 18, sessionsIndexRow, sessionRow);
+
+ // PreflightCounts derive from each step's RowCount, plus the AGGREGATE decrements length.
+ manifest.PreflightCounts = ComputePreflightCounts(manifest);
+
+ // SchemaHash is over the entire manifest content (with SchemaHash field blanked) so the
+ // worker can detect tampering between snapshot upload and pickup.
+ manifest.SchemaHash = ComputeSchemaHash(manifest);
+
+ return manifest;
+ }
+
+ // ============================================================ Step builders ============
+
+ private async Task AddPkBySessionStepAsync(
+ DeletionManifest manifest, int order, string table,
+ string compositeSessionPk, string safeTenantId, string safeSessionId,
+ CancellationToken cancellationToken)
+ {
+ var filter = $"PartitionKey eq '{safeTenantId}_{safeSessionId}'";
+ var rows = new List();
+ await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken))
+ {
+ rows.Add(MapEntityToDump(entity));
+ }
+ manifest.Steps.Add(new DeletionStep
+ {
+ Order = order,
+ Table = table,
+ Class = DeletionStepClass.PkBySession,
+ RowCount = rows.Count,
+ Rows = rows,
+ });
+ }
+
+ private async Task AddPropTenantPkStepAsync(
+ DeletionManifest manifest, int order, string table,
+ string tenantId, string sessionId, string safeTenantId, string safeSessionId,
+ CancellationToken cancellationToken)
+ {
+ var filter = $"PartitionKey eq '{safeTenantId}' and SessionId eq '{safeSessionId}'";
+ var rows = new List();
+ await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken))
+ {
+ rows.Add(MapEntityToDump(entity));
+ }
+ manifest.Steps.Add(new DeletionStep
+ {
+ Order = order,
+ Table = table,
+ Class = DeletionStepClass.PropTenantPk,
+ RowCount = rows.Count,
+ Rows = rows,
+ });
+ }
+
+ private async Task AddPkRkExactStepAsync(
+ DeletionManifest manifest, int order, string table,
+ string partitionKey, string rowKey,
+ CancellationToken cancellationToken)
+ {
+ var entity = await _reader.GetEntityOrNullAsync(table, partitionKey, rowKey, cancellationToken);
+ var rows = new List();
+ if (entity != null)
+ {
+ rows.Add(MapEntityToDump(entity));
+ }
+ manifest.Steps.Add(new DeletionStep
+ {
+ Order = order,
+ Table = table,
+ Class = DeletionStepClass.PkRkExact,
+ RowCount = rows.Count,
+ Rows = rows,
+ });
+ }
+
+ private async Task AddDiscriminatorPkRkSuffixStepAsync(
+ DeletionManifest manifest, int order, string table,
+ string tenantId, string sessionId, string safeTenantId,
+ CancellationToken cancellationToken)
+ {
+ // Server-side: PK prefix scan. Client-side: RK ends with "_{sessionId}".
+ var filter = $"PartitionKey ge '{safeTenantId}_' and PartitionKey lt '{safeTenantId}_~'";
+ var suffix = $"_{sessionId}";
+ var rows = new List();
+ await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken))
+ {
+ if (entity.RowKey != null && entity.RowKey.EndsWith(suffix, StringComparison.Ordinal))
+ {
+ rows.Add(MapEntityToDump(entity));
+ }
+ }
+ manifest.Steps.Add(new DeletionStep
+ {
+ Order = order,
+ Table = table,
+ Class = DeletionStepClass.DiscriminatorPkRkSuffix,
+ RowCount = rows.Count,
+ Rows = rows,
+ });
+ }
+
+ private async Task AddDiscriminatorPkRkExactStepAsync(
+ DeletionManifest manifest, int order, string table,
+ string tenantId, string sessionId, string safeTenantId,
+ CancellationToken cancellationToken)
+ {
+ // Server-side: PK prefix scan. Client-side: RK == sessionId.
+ var filter = $"PartitionKey ge '{safeTenantId}_' and PartitionKey lt '{safeTenantId}_~'";
+ var rows = new List();
+ await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken))
+ {
+ if (string.Equals(entity.RowKey, sessionId, StringComparison.Ordinal))
+ {
+ rows.Add(MapEntityToDump(entity));
+ }
+ }
+ manifest.Steps.Add(new DeletionStep
+ {
+ Order = order,
+ Table = table,
+ Class = DeletionStepClass.DiscriminatorPkRkExact,
+ RowCount = rows.Count,
+ Rows = rows,
+ });
+ }
+
+ private async Task AddDiscriminatorPkPropStepAsync(
+ DeletionManifest manifest, int order, string table,
+ string tenantId, string sessionId, string safeTenantId, string safeSessionId,
+ CancellationToken cancellationToken)
+ {
+ // Server-side: PK prefix scan AND SessionId eq '{sessionId}'. Eligible because every
+ // DISCRIMINATOR_PK_PROP table writes the SessionId property explicitly (see IndexRowKeys
+ // call sites; the writers project SessionId so the server-side filter is reliable).
+ var filter = $"PartitionKey ge '{safeTenantId}_' and PartitionKey lt '{safeTenantId}_~' and SessionId eq '{safeSessionId}'";
+ var rows = new List();
+ await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken))
+ {
+ rows.Add(MapEntityToDump(entity));
+ }
+ manifest.Steps.Add(new DeletionStep
+ {
+ Order = order,
+ Table = table,
+ Class = DeletionStepClass.DiscriminatorPkProp,
+ RowCount = rows.Count,
+ Rows = rows,
+ });
+ }
+
+ private void AddSoftwareInventoryDecrementStep(DeletionManifest manifest, int order, TableEntity contributionsRow)
+ {
+ var decrements = DecodeDecrementKeys(contributionsRow);
+ manifest.Steps.Add(new DeletionStep
+ {
+ Order = order,
+ Step = DeletionStepNames.SoftwareInventoryDecrement,
+ Class = DeletionStepClass.Aggregate,
+ RowCount = decrements.Count,
+ Rows = new List(),
+ Decrements = decrements,
+ });
+ }
+
+ private void AddContributionsRowStep(DeletionManifest manifest, int order, TableEntity contributionsRow)
+ {
+ manifest.Steps.Add(new DeletionStep
+ {
+ Order = order,
+ Table = Constants.TableNames.SessionInventoryContributions,
+ Class = DeletionStepClass.PkRkExact,
+ RowCount = 1,
+ Rows = new List { MapEntityToDump(contributionsRow) },
+ });
+ }
+
+ private void AddTombstoneStep(DeletionManifest manifest, int order, TableEntity? sessionsIndexRow, TableEntity? sessionRow)
+ {
+ var rows = new List(2);
+ // §5 PR4 mandates SessionsIndex deleted FIRST so UI listings drop the session before the canonical row goes.
+ if (sessionsIndexRow != null)
+ {
+ rows.Add(MapEntityToDump(sessionsIndexRow));
+ }
+ if (sessionRow != null)
+ {
+ rows.Add(MapEntityToDump(sessionRow));
+ }
+ manifest.Steps.Add(new DeletionStep
+ {
+ Order = order,
+ Step = DeletionStepNames.Tombstone,
+ Class = DeletionStepClass.Final,
+ RowCount = rows.Count,
+ Rows = rows,
+ });
+ }
+
+ // ============================================================ Mapping + helpers ========
+
+ ///
+ /// Locates the SessionsIndex row corresponding to the given Sessions row. Tries the
+ /// direct (PartitionKey, RowKey) lookup first using Sessions.IndexRowKey; falls
+ /// back to a partition-targeted scan on PartitionKey eq {tenantId} and SessionId eq
+ /// {sessionId} when the IndexRowKey is missing or stale, so a partially-corrupted
+ /// Sessions row can't strand a SessionsIndex orphan after cascade.
+ ///
+ private async Task ResolveSessionsIndexRowAsync(
+ string tenantId, string sessionId, TableEntity sessionRow, CancellationToken cancellationToken)
+ {
+ var indexRowKey = sessionRow.GetString("IndexRowKey");
+ if (!string.IsNullOrEmpty(indexRowKey))
+ {
+ var byKey = await _reader.GetSessionsIndexRowAsync(tenantId, indexRowKey!, cancellationToken);
+ if (byKey != null) return byKey;
+ }
+
+ // Fallback: partition-targeted property scan. SessionsIndex stores SessionId as a
+ // typed column on every row; a single match is expected.
+ var safeTenantId = ODataSanitizer.EscapeValue(tenantId);
+ var safeSessionId = ODataSanitizer.EscapeValue(sessionId);
+ var filter = $"PartitionKey eq '{safeTenantId}' and SessionId eq '{safeSessionId}'";
+ await foreach (var match in _reader.QueryAsync(Constants.TableNames.SessionsIndex, filter, cancellationToken))
+ {
+ return match;
+ }
+ return null;
+ }
+
+ private static DeletionRowDump MapEntityToDump(TableEntity entity)
+ {
+ var props = new Dictionary(entity.Count, StringComparer.Ordinal);
+ foreach (var key in entity.Keys)
+ {
+ if (SystemPropertyNames.Contains(key)) continue;
+ props[key] = ConvertToPropValue(entity[key]);
+ }
+ return new DeletionRowDump
+ {
+ Pk = entity.PartitionKey ?? string.Empty,
+ Rk = entity.RowKey ?? string.Empty,
+ Etag = entity.ETag.ToString(),
+ Props = props,
+ };
+ }
+
+ ///
+ /// Converts a single TableEntity property value into an EDM-tagged
+ /// . Restore reads
+ /// to choose the right strongly-typed entity setter — without the tag, a DateTime
+ /// timestamp coming out of JSON would be re-inserted as a plain string and the Azure
+ /// Tables column would silently change EDM type.
+ ///
+ private static DeletionPropValue ConvertToPropValue(object? value)
+ {
+ // null → null JsonElement, EdmType=String (Azure Tables has no first-class null type).
+ if (value == null)
+ {
+ return new DeletionPropValue
+ {
+ EdmType = DeletionPropEdmType.String,
+ Value = ParseJson("null"),
+ };
+ }
+
+ switch (value)
+ {
+ case string s:
+ return new DeletionPropValue { EdmType = DeletionPropEdmType.String, Value = ParseJson(JsonSerializer.Serialize(s)) };
+ case bool b:
+ return new DeletionPropValue { EdmType = DeletionPropEdmType.Boolean, Value = ParseJson(b ? "true" : "false") };
+ case int i:
+ return new DeletionPropValue { EdmType = DeletionPropEdmType.Int32, Value = ParseJson(i.ToString(System.Globalization.CultureInfo.InvariantCulture)) };
+ case long l:
+ return new DeletionPropValue { EdmType = DeletionPropEdmType.Int64, Value = ParseJson(l.ToString(System.Globalization.CultureInfo.InvariantCulture)) };
+ case double d:
+ return new DeletionPropValue { EdmType = DeletionPropEdmType.Double, Value = ParseJson(JsonSerializer.Serialize(d)) };
+ case System.Guid g:
+ return new DeletionPropValue { EdmType = DeletionPropEdmType.Guid, Value = ParseJson(JsonSerializer.Serialize(g.ToString("D"))) };
+ case byte[] bytes:
+ return new DeletionPropValue { EdmType = DeletionPropEdmType.Binary, Value = ParseJson(JsonSerializer.Serialize(Convert.ToBase64String(bytes))) };
+ case DateTimeOffset dto:
+ return new DeletionPropValue { EdmType = DeletionPropEdmType.DateTime, Value = ParseJson(JsonSerializer.Serialize(dto.UtcDateTime.ToString("o", System.Globalization.CultureInfo.InvariantCulture))) };
+ case DateTime dt:
+ return new DeletionPropValue { EdmType = DeletionPropEdmType.DateTime, Value = ParseJson(JsonSerializer.Serialize(dt.ToUniversalTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture))) };
+ default:
+ // Unknown type — fall back to round-tripping through JSON as a string.
+ return new DeletionPropValue
+ {
+ EdmType = DeletionPropEdmType.String,
+ Value = ParseJson(JsonSerializer.Serialize(value.ToString() ?? string.Empty)),
+ };
+ }
+ }
+
+ private static JsonElement ParseJson(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+
+ ///
+ /// Decodes the side-row's SoftwareKeysJson (raw JSON or gzip+Base64 per §17.7) into
+ /// the canonical list. Sorted deterministically by
+ /// (Vendor, Name, Version) so the manifest is byte-stable across builds with identical input.
+ ///
+ private List DecodeDecrementKeys(TableEntity contributionsRow)
+ {
+ var rawJson = contributionsRow.GetString("SoftwareKeysJson") ?? string.Empty;
+ var isCompressed = contributionsRow.GetBoolean("IsCompressed") ?? false;
+ if (string.IsNullOrEmpty(rawJson))
+ {
+ return new List();
+ }
+
+ string decoded;
+ if (isCompressed)
+ {
+ try
+ {
+ var gz = Convert.FromBase64String(rawJson);
+ using var input = new MemoryStream(gz);
+ using var gunzip = new GZipStream(input, CompressionMode.Decompress);
+ using var output = new MemoryStream();
+ gunzip.CopyTo(output);
+ decoded = Encoding.UTF8.GetString(output.ToArray());
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidDataException(
+ $"SessionInventoryContributions row for tenant {contributionsRow.PartitionKey} session {contributionsRow.RowKey} " +
+ "is marked IsCompressed=true but SoftwareKeysJson does not decode as Base64+gzip", ex);
+ }
+ }
+ else
+ {
+ decoded = rawJson;
+ }
+
+ List keys;
+ try
+ {
+ keys = JsonSerializer.Deserialize>(decoded, DeletionManifestJson.SerializerOptions)
+ ?? new List();
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidDataException(
+ $"SessionInventoryContributions.SoftwareKeysJson for tenant {contributionsRow.PartitionKey} session {contributionsRow.RowKey} " +
+ "is not valid JSON for List", ex);
+ }
+
+ return keys
+ .OrderBy(k => k.Vendor, StringComparer.Ordinal)
+ .ThenBy(k => k.Name, StringComparer.Ordinal)
+ .ThenBy(k => k.Version, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ private static Dictionary ComputePreflightCounts(DeletionManifest manifest)
+ {
+ // Convention: well-known camelCase keys per the §3 "preflightCounts" example.
+ var counts = new Dictionary(StringComparer.Ordinal);
+ foreach (var step in manifest.Steps)
+ {
+ if (step.Class == DeletionStepClass.Final) continue; // Tombstone is not a "count" entry
+ if (step.Class == DeletionStepClass.Aggregate)
+ {
+ counts["softwareInventoryDecrement"] = step.Decrements?.Count ?? 0;
+ continue;
+ }
+ if (string.IsNullOrEmpty(step.Table)) continue;
+ counts[CamelCase(step.Table!)] = step.RowCount;
+ }
+ return counts;
+ }
+
+ private static string CamelCase(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return s;
+ return char.ToLowerInvariant(s[0]) + s.Substring(1);
+ }
+
+ private static string ComputeSchemaHash(DeletionManifest manifest)
+ {
+ // Hash over the manifest with SchemaHash blanked out, so the field can reproducibly
+ // be compared by the worker after download. See plan §3.
+ var savedHash = manifest.SchemaHash;
+ manifest.SchemaHash = string.Empty;
+ try
+ {
+ var json = JsonSerializer.SerializeToUtf8Bytes(manifest, HashSerializerOptions);
+ using var sha = SHA256.Create();
+ var hash = sha.ComputeHash(json);
+ return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
+ }
+ finally
+ {
+ manifest.SchemaHash = savedHash;
+ }
+ }
+
+ private static string NewManifestId()
+ {
+ // ULID-shaped string: lex-sortable timestamp prefix + random tail. PR1 doesn't need a
+ // strict ULID library; the producer (PR3) will revisit if downstream consumers require
+ // monotonic ordering across producers.
+ var ticks = DateTime.UtcNow.Ticks;
+ var prefix = ticks.ToString("X16");
+ var tail = Guid.NewGuid().ToString("N").Substring(0, 16).ToUpperInvariant();
+ return prefix + "_" + tail;
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/ISessionDeletionInventoryReader.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/ISessionDeletionInventoryReader.cs
new file mode 100644
index 00000000..9560a423
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/ISessionDeletionInventoryReader.cs
@@ -0,0 +1,42 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.Data.Tables;
+
+namespace AutopilotMonitor.Functions.Services.Deletion
+{
+ ///
+ /// Read-only access surface used by to enumerate every
+ /// table targeted by the cascade. Narrow on purpose: the builder gets exactly the four
+ /// shapes it needs, nothing more — keeps the test stub trivial. Production implementation is
+ /// the TableStorageService.Deletion.cs partial; tests inject a Moq.
+ ///
+ public interface ISessionDeletionInventoryReader
+ {
+ ///
+ /// Loads the canonical Sessions row, used for IndexRowKey lookup +
+ /// DiagnosticsBlobName capture (PR1) and DeletionState/PendingDeletionManifestId
+ /// (PR3+ — ignored in PR1). Returns null when the row does not exist.
+ ///
+ Task GetSessionRowAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Loads a SessionsIndex row by its (tenantId, indexRowKey) coordinate. Used for the
+ /// FINAL tombstone step's row dump. Returns null when missing (treated as already-gone).
+ ///
+ Task GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken cancellationToken = default);
+
+ ///
+ /// Streams entities matching on .
+ /// The builder formulates the OData filter per step class; this method is a pure passthrough.
+ ///
+ IAsyncEnumerable QueryAsync(string tableName, string filter, CancellationToken cancellationToken = default);
+
+ ///
+ /// Direct (PK, RK) lookup against . Returns null on 404,
+ /// rethrows any other . Used for PK_RK_EXACT
+ /// classes (VulnerabilityReports, DeviceSnapshot, EventSessionIndex, SessionInventoryContributions).
+ ///
+ Task GetEntityOrNullAsync(string tableName, string partitionKey, string rowKey, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Deletion.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Deletion.cs
new file mode 100644
index 00000000..a2fd2404
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Deletion.cs
@@ -0,0 +1,72 @@
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure;
+using Azure.Data.Tables;
+using AutopilotMonitor.Functions.Services.Deletion;
+
+namespace AutopilotMonitor.Functions.Services
+{
+ ///
+ /// Cascade-deletion read surface. Implements
+ /// against the existing _tableServiceClient. Pure delegation; fail-loud on every
+ /// non-404 storage error per memory feedback_storage_helpers_fail_soft.
+ ///
+ public partial class TableStorageService : ISessionDeletionInventoryReader
+ {
+ public async Task GetSessionRowAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default)
+ {
+ var tableClient = _tableServiceClient.GetTableClient(Shared.Constants.TableNames.Sessions);
+ try
+ {
+ var response = await tableClient.GetEntityAsync(tenantId, sessionId, cancellationToken: cancellationToken);
+ return response.Value;
+ }
+ catch (RequestFailedException ex) when (ex.Status == 404)
+ {
+ return null;
+ }
+ }
+
+ public async Task GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken cancellationToken = default)
+ {
+ var tableClient = _tableServiceClient.GetTableClient(Shared.Constants.TableNames.SessionsIndex);
+ try
+ {
+ var response = await tableClient.GetEntityAsync(tenantId, indexRowKey, cancellationToken: cancellationToken);
+ return response.Value;
+ }
+ catch (RequestFailedException ex) when (ex.Status == 404)
+ {
+ return null;
+ }
+ }
+
+ public async IAsyncEnumerable QueryAsync(
+ string tableName,
+ string filter,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var tableClient = _tableServiceClient.GetTableClient(tableName);
+ await foreach (var entity in tableClient.QueryAsync(filter, cancellationToken: cancellationToken))
+ {
+ yield return entity;
+ }
+ }
+
+ public async Task GetEntityOrNullAsync(string tableName, string partitionKey, string rowKey, CancellationToken cancellationToken = default)
+ {
+ var tableClient = _tableServiceClient.GetTableClient(tableName);
+ try
+ {
+ var response = await tableClient.GetEntityAsync(partitionKey, rowKey, cancellationToken: cancellationToken);
+ return response.Value;
+ }
+ catch (RequestFailedException ex) when (ex.Status == 404)
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index 62ae72cd..dadac4ae 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -431,6 +431,12 @@ namespace AutopilotMonitor.Shared
// Persistent software inventory per tenant (aggregated from enrollment snapshots)
public const string SoftwareInventory = "SoftwareInventory";
+ // One side-row per inventory-correlated session, keyed by (tenantId, sessionId).
+ // Drives at-most-once-per-session inventory counter increments via delta-update,
+ // and gives the cascade-delete the exact decrement keys at preflight time.
+ // Written by VulnerabilityCorrelationService (PR2); read by DeletionManifestBuilder (PR1).
+ public const string SessionInventoryContributions = "SessionInventoryContributions";
+
// SLA breach status per tenant (one row per tenant, RowKey = "status").
// Persists across host recycles so SLA-breach notifications can be throttled
// reliably and a GA cross-tenant overview can be served without re-aggregation.
@@ -507,6 +513,7 @@ namespace AutopilotMonitor.Shared
VulnerabilityCache,
VulnerabilityReports,
SoftwareInventory,
+ SessionInventoryContributions,
SlaTenantStatus,
EventTypeIndex,
DeviceSnapshot,
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs b/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs
new file mode 100644
index 00000000..fe2454c6
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AutopilotMonitor.Shared.Models.Deletion
+{
+ ///
+ /// Snapshot manifest for cascade-delete of a single session. The full set of rows targeted
+ /// by the cascade is captured here at preflight time, before any delete fires. This blob
+ /// is both the contract (cascade only deletes what's listed) and the backup (restore replays
+ /// the dumps back into the tables). See plan §3 for the canonical schema.
+ ///
+ public class DeletionManifest
+ {
+ /// ULID-shaped identifier of this manifest. Bound to a single producer attempt.
+ public string ManifestId { get; set; } = string.Empty;
+
+ /// Schema version for forward-compatibility. v1 = 1.
+ public int ManifestVersion { get; set; } = 1;
+
+ public string TenantId { get; set; } = string.Empty;
+ public string SessionId { get; set; } = string.Empty;
+
+ /// UTC timestamp at which the manifest was built.
+ public DateTime CreatedAt { get; set; }
+
+ /// Who or what triggered the cascade.
+ public DeletionActor CreatedBy { get; set; } = new DeletionActor();
+
+ /// Free-form reason string (e.g. "admin_delete", "retention_cutoff").
+ public string Reason { get; set; } = string.Empty;
+
+ /// Per-tenant retention context at the moment the cascade was triggered.
+ public DeletionRetentionContext RetentionContext { get; set; } = new DeletionRetentionContext();
+
+ /// Row counts per logical bucket; convenient for audit + monitoring without parsing every step.
+ public Dictionary PreflightCounts { get; set; } = new Dictionary();
+
+ ///
+ /// Customer-storage diagnostics blob name (informational only — cascade does NOT delete
+ /// the blob; customer owns the storage account). See plan §3 + §11.
+ ///
+ public string? DiagnosticsBlobName { get; set; }
+
+ /// Ordered cascade steps. The worker executes them in order.
+ public List Steps { get; set; } = new List();
+
+ /// SHA-256 hash of the manifest contents (excluding this field). Used to detect tampering.
+ public string SchemaHash { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Identifies the trigger source of a cascade. is one of
+ /// "admin" or "maintenance"; is the user identity for
+ /// admin paths or "system" for maintenance.
+ ///
+ public class DeletionActor
+ {
+ public string Type { get; set; } = string.Empty;
+ public string Actor { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Per-tenant retention parameters captured at cascade-build time. Carried in the manifest
+ /// so the audit log shows *which* retention policy applied to a given retention-driven delete.
+ ///
+ public class DeletionRetentionContext
+ {
+ public int? TenantRetentionDays { get; set; }
+ public DateTime? CutoffUtc { get; set; }
+ }
+
+ ///
+ /// One ordered step in the cascade. Either targets a real table ( set,
+ /// null) or a synthetic operation ( set,
+ /// null — only the SoftwareInventoryDecrement and Tombstone steps).
+ /// is one of the values defined in .
+ ///
+ public class DeletionStep
+ {
+ public int Order { get; set; }
+
+ /// Table name; null for synthetic steps (Tombstone, SoftwareInventoryDecrement).
+ public string? Table { get; set; }
+
+ /// Synthetic step name; null for table-targeted steps. Values from .
+ public string? Step { get; set; }
+
+ /// Key-class taxonomy. Values from .
+ public string Class { get; set; } = string.Empty;
+
+ public int RowCount { get; set; }
+
+ /// Full row dumps. Empty for AGGREGATE steps; the Tombstone step always has 2 entries.
+ public List Rows { get; set; } = new List();
+
+ /// Decrement keys for the AGGREGATE step (SoftwareInventoryDecrement); null for all other classes.
+ public List? Decrements { get; set; }
+ }
+
+ ///
+ /// Captured snapshot of a single Azure Table row. preserves the original
+ /// Azure Tables EDM type alongside the JSON value, so a restore can re-Insert the row with
+ /// the same column types — a bare alone would collapse
+ /// DateTime / Guid / byte[] / long into ambiguous strings, breaking the
+ /// manifest-as-backup guarantee (plan §3 "types preserved").
+ ///
+ public class DeletionRowDump
+ {
+ public string Pk { get; set; } = string.Empty;
+ public string Rk { get; set; } = string.Empty;
+
+ /// ETag as observed at preflight; informational only (not used for restore).
+ public string? Etag { get; set; }
+
+ public Dictionary Props { get; set; } = new Dictionary();
+ }
+
+ ///
+ /// Single Azure Table column value with its EDM type tag. Restore looks at
+ /// to decide which Get* / strongly-typed entity setter to use; without the tag, a string
+ /// timestamp coming out of JSON would be re-inserted as a literal string and the column would
+ /// silently change shape.
+ ///
+ public class DeletionPropValue
+ {
+ ///
+ /// One of: String, Int32, Int64, Double, Boolean,
+ /// DateTime, Guid, Binary. Matches the Azure Tables OData EDM
+ /// type names (minus the Edm. prefix).
+ ///
+ public string EdmType { get; set; } = DeletionPropEdmType.String;
+
+ /// JSON-encoded value. For DateTime / Guid / Binary this is the ISO-8601 /
+ /// GUID-string / Base64 form respectively.
+ public JsonElement Value { get; set; }
+ }
+
+ /// EDM type constants used in .
+ public static class DeletionPropEdmType
+ {
+ public const string String = "String";
+ public const string Int32 = "Int32";
+ public const string Int64 = "Int64";
+ public const string Double = "Double";
+ public const string Boolean = "Boolean";
+ public const string DateTime = "DateTime";
+ public const string Guid = "Guid";
+ public const string Binary = "Binary";
+ }
+
+ ///
+ /// One key targeted by the SoftwareInventoryDecrement AGGREGATE step. The cascade applies
+ /// SessionCount -= 1 with clamp-≥-0 on the matching SoftwareInventory row.
+ /// Shape mirrors the increment side in VulnerabilityCorrelationService.
+ ///
+ public class DeletionDecrementKey
+ {
+ public string Vendor { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string Version { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Per-step key-class taxonomy. Encodes how preflight enumerates the table and how the worker
+ /// deletes (always by exact (PK,RK) at cascade time — see plan §1 P6 for why).
+ ///
+ public static class DeletionStepClass
+ {
+ public const string PkBySession = "PK_BY_SESSION";
+ public const string PkRkExact = "PK_RK_EXACT";
+ public const string PropTenantPk = "PROP_TENANT_PK";
+ public const string DiscriminatorPkRkSuffix = "DISCRIMINATOR_PK_RK_SUFFIX";
+ public const string DiscriminatorPkRkExact = "DISCRIMINATOR_PK_RK_EXACT";
+ public const string DiscriminatorPkProp = "DISCRIMINATOR_PK_PROP";
+ public const string Aggregate = "AGGREGATE";
+ public const string Final = "FINAL";
+ }
+
+ /// Synthetic step names for non-table cascade operations.
+ public static class DeletionStepNames
+ {
+ public const string SoftwareInventoryDecrement = "SoftwareInventoryDecrement";
+ public const string Tombstone = "Tombstone";
+ }
+
+ ///
+ /// Centralized JSON options for manifest (de)serialization. CamelCase casing matches the
+ /// schema documented in plan §3.
+ ///
+ public static class DeletionManifestJson
+ {
+ public static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ // DictionaryKeyPolicy is intentionally NOT set: uses
+ // the original Azure Table column names (e.g. "EventType", "MaxSeverity") as dictionary
+ // keys — those MUST round-trip byte-faithfully so a restore can re-insert the row with
+ // the correct column names. keys are
+ // already camelCase by construction.
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false,
+ };
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionProgress.cs b/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionProgress.cs
new file mode 100644
index 00000000..891701ed
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionProgress.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+
+namespace AutopilotMonitor.Shared.Models.Deletion
+{
+ ///
+ /// Mutable progress companion to . Tracks which steps have
+ /// already executed and whether the live verification pass has run. Plan §3 (Round-2 R9):
+ /// schema is intentionally minimal — observability data goes to AuditLogs, not here.
+ /// Stored as the sibling {manifestId}.progress.json blob and CAS'd on every write.
+ ///
+ public class DeletionProgress
+ {
+ /// SHA-256 of the immutable snapshot blob; mismatch = corruption, refuse to proceed.
+ public string SnapshotSha256 { get; set; } = string.Empty;
+
+ /// Step.Order values that have completed; the worker iterates the remaining ones.
+ public HashSet CompletedSteps { get; set; } = new HashSet();
+
+ /// True once the live verification pass succeeded; gates the FINAL tombstone step.
+ public bool VerificationDone { get; set; }
+
+ /// UTC timestamp once the FINAL tombstone step has completed; null while in flight.
+ public DateTime? CompletedAt { get; set; }
+ }
+}