From f2e899aaadc134382149dde3a40bb9252f5db404 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:55:46 -0400 Subject: [PATCH] feat(r2-04): PrimaryGatePolicy - membership-aware default-deny + denial meter (03/S4) --- ...2-04-failure-visibility-plan.md.tasks.json | 2 +- .../Observability/OtOpcUaTelemetry.cs | 6 +++ .../Drivers/PrimaryGatePolicy.cs | 28 ++++++++++++++ .../Drivers/PrimaryGatePolicyTests.cs | 38 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/PrimaryGatePolicyTests.cs diff --git a/archreview/plans/R2-04-failure-visibility-plan.md.tasks.json b/archreview/plans/R2-04-failure-visibility-plan.md.tasks.json index df8e7382..703f3baf 100644 --- a/archreview/plans/R2-04-failure-visibility-plan.md.tasks.json +++ b/archreview/plans/R2-04-failure-visibility-plan.md.tasks.json @@ -10,7 +10,7 @@ { "id": "T6", "subject": "Galaxy fail-closed write on AdviseSupervisory failure -> BadCommunicationError, no raw write", "status": "completed", "blockedBy": ["T5"] }, { "id": "T7", "subject": "Galaxy counters galaxy.writes.advise_failed + galaxy.writes.unconfirmed (empty-statuses Good metered)", "status": "completed", "blockedBy": ["T6"] }, { "id": "T8", "subject": "Galaxy skip-gated live smoke: normal advised write still returns Good post-change", "status": "completed", "blockedBy": ["T6"] }, - { "id": "T9", "subject": "PrimaryGatePolicy pure class + truth-table tests + otopcua.redundancy.primary_gate_denied counter (03/S4)", "status": "pending", "blockedBy": [] }, + { "id": "T9", "subject": "PrimaryGatePolicy pure class + truth-table tests + otopcua.redundancy.primary_gate_denied counter (03/S4)", "status": "completed", "blockedBy": [] }, { "id": "T10", "subject": "Wire DriverHostActor gates (:977/:1039/:1095) through PrimaryGatePolicy with member-count provider + S5 log-once", "status": "pending", "blockedBy": ["T9"] }, { "id": "T11", "subject": "DriverHostActorPrimaryGateTests: deny-on-unknown multi-node, allow single-node, ack/emit variants, meter tags", "status": "pending", "blockedBy": ["T10"] }, { "id": "T12", "subject": "ScriptedAlarmHostActor alerts-emit gate adopts PrimaryGatePolicy (consistency extension)", "status": "pending", "blockedBy": ["T9"] }, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs index 210d3705..7b9d7933 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs @@ -70,6 +70,12 @@ public static class OtOpcUaTelemetry Meter.CreateCounter("otopcua.redundancy.service_level_change", unit: "{change}", description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state."); + /// Inbound operations denied by the Primary data-plane gate (archreview 03/S4) — + /// site=write|ack|alarm-emit, reason=secondary|detached|role-unknown. + public static readonly Counter PrimaryGateDenied = + Meter.CreateCounter("otopcua.redundancy.primary_gate_denied", unit: "{denial}", + description: "Operations denied by the Primary gate (site=write|ack|alarm-emit, reason=secondary|detached|role-unknown)."); + // ---------------- Convenience helpers ---------------- /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs new file mode 100644 index 00000000..90486a19 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs @@ -0,0 +1,28 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// +/// Single decision point for the Primary data-plane gates (inbound device writes, native alarm acks, +/// fleet-wide alerts publishes). A KNOWN role wins outright; an UNKNOWN role (no redundancy snapshot +/// yet, or the snapshot never mentioned this node — the 03/S5 identity-mismatch shape) is resolved by +/// cluster membership: a single-driver cluster stays default-ALLOW (no peer exists — the +/// boot-window / single-node posture), a multi-driver cluster is default-DENY (a real Primary peer +/// exists; do not touch the shared field device until the redundancy snapshot proves this node is it). +/// Fixes archreview 03/S4 (primary gate defaulted to allow while role unknown → dual-primary window). +/// +public static class PrimaryGatePolicy +{ + /// Decide whether this node should service a Primary-only data-plane operation. + /// This node's last-known redundancy role, or null when unknown + /// (no snapshot received yet, or the snapshot omitted this node). + /// Count of Up cluster members carrying the driver role. + /// true to service as Primary; false to deny. + public static bool ShouldServiceAsPrimary(RedundancyRole? localRole, int driverMemberCount) => + localRole switch + { + RedundancyRole.Primary => true, + RedundancyRole.Secondary or RedundancyRole.Detached => false, + _ => driverMemberCount <= 1, // unknown: allow only when no driver peer exists + }; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/PrimaryGatePolicyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/PrimaryGatePolicyTests.cs new file mode 100644 index 00000000..07dac1d6 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/PrimaryGatePolicyTests.cs @@ -0,0 +1,38 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// archreview 03/S4 — the single Primary data-plane gate decision. A KNOWN role wins outright; an +/// UNKNOWN role (no snapshot yet, or the snapshot never mentioned this node) is resolved by cluster +/// membership: a single-driver cluster stays default-ALLOW (boot-window / single-node posture), a +/// multi-driver cluster is default-DENY (a real Primary peer exists — don't touch the shared field +/// device until the snapshot proves this node is Primary). +/// +public sealed class PrimaryGatePolicyTests +{ + [Theory] + // Primary → always service, regardless of member count. + [InlineData(RedundancyRole.Primary, 0, true)] + [InlineData(RedundancyRole.Primary, 1, true)] + [InlineData(RedundancyRole.Primary, 2, true)] + // Secondary / Detached → never service, regardless of member count. + [InlineData(RedundancyRole.Secondary, 0, false)] + [InlineData(RedundancyRole.Secondary, 2, false)] + [InlineData(RedundancyRole.Detached, 0, false)] + [InlineData(RedundancyRole.Detached, 2, false)] + public void Known_role_wins_regardless_of_member_count(RedundancyRole role, int members, bool expected) + => PrimaryGatePolicy.ShouldServiceAsPrimary(role, members).ShouldBe(expected); + + [Theory] + // Unknown role: allow only when no driver peer exists (count <= 1). + [InlineData(0, true)] + [InlineData(1, true)] + [InlineData(2, false)] + [InlineData(3, false)] + public void Unknown_role_resolves_by_membership(int members, bool expected) + => PrimaryGatePolicy.ShouldServiceAsPrimary(null, members).ShouldBe(expected); +}