diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditChannel.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditChannel.cs
index 4f76ba84..460a600e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditChannel.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditChannel.cs
@@ -2,12 +2,14 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
///
/// Top-level Audit Log (#23) channel — the trust boundary the audited action crosses.
-/// One of: outbound API call, outbound DB write, notification send/deliver, or inbound API request.
+/// One of: outbound API call, outbound DB write, notification send/deliver, inbound API request,
+/// or a two-person ("secured") write through its submit/approve/reject/execute lifecycle.
///
public enum AuditChannel
{
ApiOutbound,
DbOutbound,
Notification,
- ApiInbound
+ ApiInbound,
+ SecuredWrite
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs
index 9ccf6704..afbc7ade 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs
@@ -3,6 +3,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
///
/// Specific Audit Log (#23) event kind within a channel — what action produced the row.
/// Cached variants emit multiple rows per operation (submit → forward → attempt → resolve).
+/// The SecuredWrite* kinds emit one row per two-person-write lifecycle event
+/// (submit → approve → execute, or submit → reject).
/// See alog.md §4 for the full taxonomy.
///
public enum AuditKind
@@ -16,5 +18,9 @@ public enum AuditKind
InboundRequest,
InboundAuthFailure,
CachedSubmit,
- CachedResolve
+ CachedResolve,
+ SecuredWriteSubmit,
+ SecuredWriteApprove,
+ SecuredWriteReject,
+ SecuredWriteExecute
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
index 5a1db8db..d36d927f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
@@ -1,3 +1,4 @@
+using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -21,6 +22,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -893,6 +896,74 @@ public class ManagementActor : ReceiveActor
e.Status, e.OperatorUser, e.OperatorComment, e.SubmittedAtUtc,
e.VerifierUser, e.VerifierComment, e.DecidedAtUtc, e.ExecutedAtUtc, e.ExecutionError);
+ ///
+ /// Deterministic, reversible map from a (a
+ /// store-assigned ) to the canonical AuditLog
+ /// CorrelationId (a ): the 8-byte big-endian id occupies
+ /// the final 8 bytes of an otherwise-zero Guid. Every row across one secured-write
+ /// lifecycle (submit → approve → execute, or submit → reject) shares this value so
+ /// they join into one operation; the encoding is stable (same id ⇒ same Guid).
+ ///
+ private static Guid SecuredWriteCorrelation(long id)
+ {
+ Span bytes = stackalloc byte[16];
+ BinaryPrimitives.WriteInt64BigEndian(bytes[8..], id);
+ return new Guid(bytes);
+ }
+
+ ///
+ /// Best-effort emission of ONE secured-write AuditLog row via the central direct-write
+ /// path () — mirrors the
+ /// Notification Outbox / Inbound API central-origin pattern. The row is built through
+ /// the canonical so Action/Category/Outcome
+ /// map identically to every other emit site.
+ ///
+ ///
+ /// Standing audit invariant: an audit-write failure NEVER aborts the secured-write
+ /// action. Every exception (repository resolution OR the insert) is caught, logged at
+ /// warning, and swallowed — the caller's own success/failure path is authoritative.
+ ///
+ private static async Task EmitSecuredWriteAuditAsync(
+ IServiceProvider sp,
+ AuditKind kind,
+ AuditStatus status,
+ PendingSecuredWrite row,
+ string actor,
+ string? errorMessage = null)
+ {
+ try
+ {
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.SecuredWrite,
+ kind: kind,
+ status: status,
+ actor: actor,
+ target: $"{row.SiteId}/{row.ConnectionName}/{row.TagPath}",
+ correlationId: SecuredWriteCorrelation(row.Id),
+ sourceSiteId: row.SiteId,
+ errorMessage: errorMessage,
+ // Carry the counterparty (operator on a verifier-actioned row, and
+ // vice-versa) so a single row names both parties to the two-person write.
+ extra: JsonSerializer.Serialize(new
+ {
+ operatorUser = row.OperatorUser,
+ verifierUser = row.VerifierUser
+ }));
+
+ using var scope = sp.CreateScope();
+ var auditRepo = scope.ServiceProvider.GetRequiredService();
+ await auditRepo.InsertIfNotExistsAsync(evt);
+ }
+ catch (Exception ex)
+ {
+ // Audit is best-effort — swallow + log; never abort the secured-write action.
+ sp.GetService>()?.LogWarning(
+ ex,
+ "Best-effort secured-write audit emission failed (kind={Kind}, id={Id}); the write itself is unaffected.",
+ kind, row.Id);
+ }
+ }
+
private static async Task