diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs
index 96448775..e424c60e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs
@@ -22,6 +22,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
/// Requested page size. The actor clamps this to the [1, 200] range, so
/// the effective ceiling is 200 rows per page regardless of the value sent.
///
+///
+/// Exact-match on one operation (the ?correlationId= deep link from the
+/// Flow Debugger / Audit Log); null = no filter. Additive, trailing
+/// optional so existing positional callers are unaffected.
+///
public sealed record SiteCallQueryRequest(
string CorrelationId,
string? StatusFilter,
@@ -34,7 +39,8 @@ public sealed record SiteCallQueryRequest(
DateTime? AfterCreatedAtUtc,
Guid? AfterId,
int PageSize,
- string? SourceNodeFilter = null);
+ string? SourceNodeFilter = null,
+ Guid? TrackedOperationIdFilter = null);
///
/// A single SiteCalls row summarised for the Site Calls UI grid. Carries
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallQueryFilter.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallQueryFilter.cs
index 82d37a57..3258b929 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallQueryFilter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallQueryFilter.cs
@@ -31,6 +31,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// "site-plant-a-node-a"). Exact match; null means "do not
/// constrain". Rows with NULL SourceNode are excluded when set.
///
+///
+/// Restrict to exactly one cached call by its primary key — the
+/// ?correlationId= deep link from the Flow Debugger / Audit Log.
+/// null means "do not constrain". AND-combined with every other field,
+/// so a mismatching (etc.) still yields no rows.
+///
public sealed record SiteCallQueryFilter(
string? Channel = null,
string? SourceSite = null,
@@ -39,4 +45,5 @@ public sealed record SiteCallQueryFilter(
DateTime? FromUtc = null,
DateTime? ToUtc = null,
DateTime? StuckCutoffUtc = null,
- string? SourceNode = null);
+ string? SourceNode = null,
+ Guid? TrackedOperationId = null);
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs
index de37235c..19d9f1ea 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs
@@ -294,6 +294,12 @@ WHERE TrackedOperationId = @Id
var fromUtc = filter.FromUtc;
var toUtc = filter.ToUtc;
var stuckCutoff = filter.StuckCutoffUtc;
+ // TrackedOperationId is a varchar(36) column holding the "D"-format GUID
+ // string (see SiteCallEntityTypeConfiguration), so the exact-match filter
+ // is compared as a string — same shape as the @AfterId cursor below. A
+ // uniqueidentifier parameter would make SQL Server convert the COLUMN side
+ // (higher type precedence) and lose the PK seek.
+ var trackedOperationIdString = filter.TrackedOperationId?.ToString("D");
// The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff)
// is pushed into SQL here — both columns are plain (no value converter)
@@ -315,6 +321,7 @@ WHERE (@Channel IS NULL OR Channel = @Channel)
AND (@SourceNode IS NULL OR SourceNode = @SourceNode)
AND (@Status IS NULL OR Status = @Status)
AND (@Target IS NULL OR Target = @Target)
+ AND (@TrackedOperationId IS NULL OR TrackedOperationId = @TrackedOperationId)
AND (@FromUtc IS NULL OR CreatedAtUtc >= @FromUtc)
AND (@ToUtc IS NULL OR CreatedAtUtc <= @ToUtc)
AND (@StuckCutoff IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < @StuckCutoff))
@@ -338,6 +345,7 @@ OPTION (RECOMPILE);";
Param("@SourceNode", SqlDbType.VarChar, filter.SourceNode),
Param("@Status", SqlDbType.VarChar, filter.Status),
Param("@Target", SqlDbType.VarChar, filter.Target),
+ Param("@TrackedOperationId", SqlDbType.VarChar, trackedOperationIdString),
Param("@FromUtc", SqlDbType.DateTime2, fromUtc),
Param("@ToUtc", SqlDbType.DateTime2, toUtc),
Param("@StuckCutoff", SqlDbType.DateTime2, stuckCutoff),
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs
index a8970ebf..46d88b91 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs
@@ -832,7 +832,8 @@ public class SiteCallAuditActor : ReceiveActor
// keyset cursor, so the page is always honest (full pages, no empty
// pages with a non-null next cursor).
StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null,
- SourceNode: NullIfBlank(request.SourceNodeFilter));
+ SourceNode: NullIfBlank(request.SourceNodeFilter),
+ TrackedOperationId: request.TrackedOperationIdFilter);
var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize);
var paging = new SiteCallPaging(
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs
index f1353bb6..f389bbec 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs
@@ -438,6 +438,40 @@ public class SiteCallAuditRepositoryTests : IClassFixture
Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite));
}
+ [SkippableFact]
+ public async Task QueryAsync_FilterByTrackedOperationId_ReturnsExactlyThatRow()
+ {
+ // Flow Debugger follow-ups Task 2: SiteCallQueryFilter.TrackedOperationId is
+ // an exact PK match (the ?correlationId= deep link). The column is varchar(36)
+ // holding the "D"-format GUID string, so the predicate must compare as a
+ // string — a uniqueidentifier parameter would convert the column side and
+ // defeat the PK seek.
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ var site = NewSiteId();
+ await using var context = CreateContext();
+ var repo = new SiteCallAuditRepository(context);
+
+ var idA = TrackedOperationId.New();
+ var idB = TrackedOperationId.New();
+ var t0 = new DateTime(2026, 9, 6, 10, 0, 0, DateTimeKind.Utc);
+ await repo.UpsertAsync(NewRow(idA, sourceSite: site, createdAtUtc: t0));
+ await repo.UpsertAsync(NewRow(idB, sourceSite: site, createdAtUtc: t0.AddMinutes(1)));
+
+ var rows = await repo.QueryAsync(
+ new SiteCallQueryFilter(TrackedOperationId: idA.Value),
+ new SiteCallPaging(PageSize: 10));
+
+ var row = Assert.Single(rows);
+ Assert.Equal(idA, row.TrackedOperationId);
+
+ // Composes with the other predicates: a mismatching site yields nothing.
+ var none = await repo.QueryAsync(
+ new SiteCallQueryFilter(SourceSite: NewSiteId(), TrackedOperationId: idA.Value),
+ new SiteCallPaging(PageSize: 10));
+ Assert.Empty(none);
+ }
+
[SkippableFact]
public async Task QueryAsync_KeysetPaging_NoOverlap()
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs
index e08ef198..c001ebf9 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs
@@ -268,6 +268,45 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture(TimeSpan.FromSeconds(10));
+ Assert.True(response.Success);
+ Assert.Single(response.SiteCalls);
+ Assert.Equal(rowA.TrackedOperationId.Value, response.SiteCalls[0].TrackedOperationId);
+ Assert.Equal("Attempted", response.SiteCalls[0].Status);
+ // No further page — the exact-match filter yields at most one row.
+ Assert.Equal(rowA.TrackedOperationId.Value, response.NextAfterId);
+ }
+
[SkippableFact]
public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor()
{