diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs
new file mode 100644
index 00000000..4bfee637
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs
@@ -0,0 +1,333 @@
+using System.Text.Json;
+using Azure.Data.Tables;
+using AutopilotMonitor.Functions.Functions.Apps;
+using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// F1 PR1 (insights spec §F1, source-data audit Q2/Q3): app identity on the name-keyed
+/// summary row and the ESP-blocking join.
+///
+/// Ingest side: appId is adopted from the app events; a second appId under the same
+/// name flags the row () — in-batch and
+/// cross-batch. Store side: sentinel-gated columns + sticky collision. Join side:
+/// is positive evidence only, and
+/// never stamps collision rows,
+/// id-less rows, or non-members. Aggregate side: collision rows leave every per-app fleet
+/// aggregate with a disclosed exclusion count.
+///
+public class AppInstallIdentityAndEspBlockingTests
+{
+ private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc);
+
+ private const string IdA = "aaaaaaaa-1111-2222-3333-444444444444";
+ private const string IdB = "bbbbbbbb-1111-2222-3333-444444444444";
+
+ // ── ingest fold: appId adoption + in-batch collision ────────────────────
+
+ private static Dictionary Aggregate(params EnrollmentEvent[] events)
+ {
+ var summaries = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var evt in events)
+ EventIngestProcessor.AggregateAppInstallEvent(evt, "tenant", "session", summaries);
+ return summaries;
+ }
+
+ private static EnrollmentEvent AppEvent(string eventType, string? appId = null, string appName = "App")
+ {
+ var evt = new EnrollmentEvent
+ {
+ EventType = eventType,
+ Timestamp = T0,
+ Data = new Dictionary { ["appName"] = appName },
+ };
+ if (appId != null) evt.Data["appId"] = appId;
+ return evt;
+ }
+
+ [Fact]
+ public void Ingest_AdoptsAppId_FromFirstObservingEvent()
+ {
+ var s = Aggregate(
+ AppEvent("app_install_started", IdA),
+ AppEvent("app_install_completed", IdA))["App"].Summary;
+
+ Assert.Equal(IdA, s.AppId);
+ Assert.False(s.AppIdCollision);
+ }
+
+ [Fact]
+ public void Ingest_SameName_DifferentAppId_FlagsCollision_FirstSeenIdWins()
+ {
+ // Device- + user-scope assignment of the same display name in one batch (audit Q3:
+ // observed in production as two "Company Portal" appIds in one session).
+ var s = Aggregate(
+ AppEvent("app_install_started", IdA),
+ AppEvent("app_install_started", IdB))["App"].Summary;
+
+ Assert.True(s.AppIdCollision);
+ Assert.Equal(IdA, s.AppId);
+ }
+
+ [Fact]
+ public void Ingest_AppIdCaseDrift_IsNotACollision()
+ {
+ var s = Aggregate(
+ AppEvent("app_install_started", IdA),
+ AppEvent("app_install_completed", IdA.ToUpperInvariant()))["App"].Summary;
+
+ Assert.False(s.AppIdCollision);
+ }
+
+ [Fact]
+ public void Ingest_EventsWithoutAppId_LeaveSentinelEmpty()
+ {
+ var s = Aggregate(AppEvent("app_install_completed"))["App"].Summary;
+ Assert.Equal(string.Empty, s.AppId);
+ Assert.False(s.AppIdCollision);
+ }
+
+ // ── reconcile: cross-batch identity ─────────────────────────────────────
+
+ [Fact]
+ public void Reconcile_AdoptsStoredAppId_WhenIncomingBatchHasNone()
+ {
+ var existing = new TableEntity("tenant", "session_App") { ["AppId"] = IdA };
+ var summary = new AppInstallSummary { AppName = "App", StartedAt = T0 };
+
+ TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
+
+ Assert.Equal(IdA, summary.AppId);
+ Assert.False(summary.AppIdCollision);
+ }
+
+ [Fact]
+ public void Reconcile_CrossBatchDifferentAppId_FlagsCollision_StoredIdWins()
+ {
+ var existing = new TableEntity("tenant", "session_App") { ["AppId"] = IdA };
+ var summary = new AppInstallSummary { AppName = "App", AppId = IdB, StartedAt = T0 };
+
+ TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
+
+ Assert.True(summary.AppIdCollision);
+ Assert.Equal(IdA, summary.AppId); // first-seen (stored) identity wins, matching the fold rule
+ }
+
+ [Fact]
+ public void Reconcile_CollisionIsSticky_AndEspBlockingVerdictIsAdopted()
+ {
+ var existing = new TableEntity("tenant", "session_App")
+ {
+ ["AppId"] = IdA,
+ ["AppIdCollision"] = true,
+ ["EspBlocking"] = true,
+ };
+ var summary = new AppInstallSummary { AppName = "App", AppId = IdA, StartedAt = T0 };
+
+ TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
+
+ Assert.True(summary.AppIdCollision); // one-way sticky
+ Assert.True(summary.EspBlocking); // terminal-path verdict survives late batches
+ }
+
+ // ── entity builder: sentinel gating ─────────────────────────────────────
+
+ [Fact]
+ public void EntityBuilder_IdentityColumns_AreSentinelGated()
+ {
+ var empty = TableStorageService.BuildAppInstallSummaryEntity(
+ new AppInstallSummary { AppName = "App" }, "rk");
+ // Absent columns → Merge-mode preserves prior values / stays unknown.
+ Assert.False(empty.ContainsKey("AppId"));
+ Assert.False(empty.ContainsKey("EspBlocking"));
+ Assert.False(empty.ContainsKey("AppIdCollision"));
+
+ var full = TableStorageService.BuildAppInstallSummaryEntity(
+ new AppInstallSummary { AppName = "App", AppId = IdA, EspBlocking = true, AppIdCollision = true }, "rk");
+ Assert.Equal(IdA, full.GetString("AppId"));
+ Assert.True(full.GetBoolean("EspBlocking"));
+ Assert.True(full.GetBoolean("AppIdCollision"));
+ }
+
+ // ── EspBlockingSets: positive-evidence parsing ──────────────────────────
+
+ private static Dictionary Lists(
+ object? win32 = null, object? userWin32 = null, object? msi = null, object? pfn = null,
+ long? win32Count = null)
+ {
+ var d = new Dictionary { ["source"] = "registry_firstsync" };
+ if (win32 != null) d["espTrackedWin32AppIds"] = win32;
+ if (userWin32 != null) d["espTrackedUserWin32AppIds"] = userWin32;
+ if (msi != null) d["espTrackedMsiProductCodes"] = msi;
+ if (pfn != null) d["espTrackedModernAppPfns"] = pfn;
+ if (win32Count.HasValue) d["espTrackedWin32Count"] = win32Count.Value;
+ return d;
+ }
+
+ [Fact]
+ public void Sets_PayloadWithoutListKeys_YieldsNull_NoListsIsNotEmptyLists()
+ {
+ Assert.Null(EspBlockingSets.FromEventData(null));
+ Assert.Null(EspBlockingSets.FromEventData(Lists())); // probe found no Diagnostics key
+ }
+
+ [Fact]
+ public void Sets_MembershipIsCaseInsensitive_AcrossAllFourLists()
+ {
+ // Storage rehydration shape: List