diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs
index f2247f59..d7f35b11 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs
@@ -54,4 +54,29 @@ public interface ISecuredWriteRepository
int skip,
int take,
CancellationToken ct = default);
+
+ ///
+ /// Atomically flips a row from Pending to Approved, stamping the
+ /// verifier identity, comment, and decision time, but ONLY if the row is still
+ /// Pending. This is the compare-and-swap guard for the two-verifier race
+ /// (M7 / T14b): two verifiers may approve the same write concurrently, but the
+ /// conditional WHERE Status='Pending' guarantees exactly one wins. The
+ /// loser observes false and must not relay the write.
+ ///
+ /// Identity of the pending secured write.
+ /// The approving verifier's username.
+ /// Optional free-text comment from the verifier.
+ /// UTC instant the approval decision was made.
+ /// Cancellation token.
+ ///
+ /// A task resolving to true if this caller won the approval (exactly one
+ /// row transitioned), or false if the row was no longer Pending
+ /// (already decided by another verifier).
+ ///
+ Task TryMarkApprovedAsync(
+ long id,
+ string verifierUser,
+ string? verifierComment,
+ DateTime decidedAtUtc,
+ CancellationToken ct = default);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
index b80f627e..b78da1af 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
@@ -417,6 +418,33 @@ public class CommunicationService
envelope, _options.QueryTimeout, cancellationToken);
}
+ // ── Secured Write Relay (M7 / T14b — approve → site MxGateway write) ──
+
+ ///
+ /// Relays a single tag write to the site's data connection and awaits the
+ /// outcome. Used by the secured-write (two-person) approve flow: once a
+ /// Verifier's approval wins the compare-and-swap, the central ManagementActor
+ /// calls this to execute the write against the site's MxGateway connection.
+ /// The request is the existing , already handled
+ /// site-side by DataConnectionActor (routed to the MxGateway adapter) —
+ /// no site-side change is required. The Ask is bounded by
+ /// , mirroring
+ /// and the other one-shot site queries.
+ ///
+ /// The target site identifier.
+ /// The tag write request (correlation id + connection + tag + value + timestamp).
+ /// Cancellation token.
+ /// The write response (success flag + optional error message).
+ public virtual Task WriteTagAsync(
+ string siteId,
+ WriteTagRequest request,
+ CancellationToken ct = default)
+ {
+ var envelope = new SiteEnvelope(siteId, request);
+ return GetActor().Ask(
+ envelope, _options.QueryTimeout, ct);
+ }
+
// ── Pattern 8: Heartbeat (site→central, Tell) ──
// Heartbeats are received by central, not sent. No method needed here.
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs
index 5fa53fe0..cc34852b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs
@@ -79,4 +79,31 @@ public class SecuredWriteRepository : ISecuredWriteRepository
.Take(take)
.ToListAsync(ct);
}
+
+ ///
+ public async Task TryMarkApprovedAsync(
+ long id,
+ string verifierUser,
+ string? verifierComment,
+ DateTime decidedAtUtc,
+ CancellationToken ct = default)
+ {
+ // Single-statement compare-and-swap: the conditional WHERE Status='Pending'
+ // makes the Pending->Approved transition atomic at the row level, so two
+ // verifiers approving concurrently produce exactly one rowsAffected==1 (the
+ // winner) and one rowsAffected==0 (the loser). Parameterised via
+ // ExecuteSqlInterpolatedAsync — same raw-SQL conditional-update pattern as
+ // SiteCallAuditRepository's upsert-on-newer-status path.
+ var rowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync(
+ $@"UPDATE dbo.PendingSecuredWrites
+SET Status = 'Approved',
+ VerifierUser = {verifierUser},
+ VerifierComment = {verifierComment},
+ DecidedAtUtc = {decidedAtUtc}
+WHERE Id = {id}
+ AND Status = 'Pending';",
+ ct);
+
+ return rowsAffected == 1;
+ }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
index 8ef7e113..5a1db8db 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
@@ -378,10 +378,11 @@ public class ManagementActor : ReceiveActor
DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user),
DebugSnapshotCommand cmd => await HandleDebugSnapshot(sp, cmd, user),
- // Secured writes (M7 / T14b). Approve (execute) is intentionally NOT
- // dispatched here — the approve->execute relay is Task C3; it would
- // 'NotSupported' at runtime until then.
+ // Secured writes (M7 / T14b). Approve executes the write — once a
+ // Verifier wins the compare-and-swap the value is relayed to the site
+ // MxGateway connection (Task C3).
SubmitSecuredWriteCommand cmd => await HandleSubmitSecuredWrite(sp, cmd, user),
+ ApproveSecuredWriteCommand cmd => await HandleApproveSecuredWrite(sp, cmd, user),
RejectSecuredWriteCommand cmd => await HandleRejectSecuredWrite(sp, cmd, user),
ListSecuredWritesCommand cmd => await HandleListSecuredWrites(sp, cmd),
@@ -928,6 +929,87 @@ public class ManagementActor : ReceiveActor
return ToSecuredWriteDto(entity);
}
+ private static async Task