docs(security,core): correct stale write-outcome doc + note benign DraftSnapshot/LeaderChanged residue (stillpending §9/§3)

This commit is contained in:
Joseph Doherty
2026-06-15 09:48:14 -04:00
parent b4af9e7f37
commit a9d267c91a
3 changed files with 24 additions and 3 deletions
+17 -1
View File
@@ -327,9 +327,25 @@ The rule is intentionally scoped to async surfaces — pure in-memory accessors
---
## Write-Outcome Self-Correction
When an OPC UA client writes a value and the driver reports a failure, the server automatically
reverts the variable node to its prior value and surfaces the Bad-quality status code to the client.
This prevents the node from showing a phantom Good value after a device-level write error. The
revert is local to the node that received the write — no cluster coordination is required.
Additionally, an `AuditWriteUpdateEvent` is emitted on every write attempt (success or failure),
consistent with OPC UA Part 4 audit requirements. The event carries the source node id, the
requested value, and the final status code so audit log consumers can trace write outcomes without
polling the node.
(Implemented in master `1d797c1c`.)
---
## Audit Logging
- **Server**: authentication, certificate-validation, and write-denial events are logged through the regular Serilog rolling file sink.
- **Server**: authentication, certificate-validation, write, and write-denial events are logged through the regular Serilog rolling file sink; write outcomes also emit an `AuditWriteUpdateEvent` (see [Write-Outcome Self-Correction](#write-outcome-self-correction)).
- **Admin**: `AuditWriterActor` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs`) writes `ConfigAuditLog` rows (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs`) to the Config DB for publish, rollback, cluster-node CRUD, and credential rotation. Visible on the cluster Audit page (`ClusterAudit.razor`) for operators with `Viewer` or above.
---
@@ -191,7 +191,9 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
{
Receive<ClusterEvent.IMemberEvent>(e => owner.HandleMemberEvent(e));
Receive<ClusterEvent.RoleLeaderChanged>(e => owner.HandleRoleLeaderChanged(e));
Receive<ClusterEvent.LeaderChanged>(_ => { /* no-op for now; reserved for ServiceLevel calc */ });
// LeaderChanged is intentionally a no-op here: ServiceLevel lives in RedundancyStateActor
// (admin-role singleton) and has no consumer on this ClusterRoleInfo path by design.
Receive<ClusterEvent.LeaderChanged>(_ => { });
Receive<ClusterEvent.CurrentClusterState>(_ => { /* seeded from initial snapshot */ });
}
@@ -31,6 +31,9 @@ public static class DraftSnapshotFactory
public static async Task<DraftSnapshot> FromConfigDbAsync(OtOpcUaConfigDbContext db, CancellationToken ct = default)
=> new DraftSnapshot
{
// GenerationId=0 and null Enterprise/Site are intentional conservative fallbacks at the
// global-factory level: the path-length validator uses its upper bound, and cross-gen
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
Namespaces = await db.Namespaces.AsNoTracking().ToListAsync(ct),
@@ -42,7 +45,7 @@ public static class DraftSnapshotFactory
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
PriorEquipment = [],
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations
.AsNoTracking()
.Where(r => r.ReleasedAt == null) // active only — matches DraftSnapshot.ActiveReservations semantics