feat(r2-04): PrimaryGatePolicy - membership-aware default-deny + denial meter (03/S4)

This commit is contained in:
Joseph Doherty
2026-07-13 10:55:46 -04:00
parent edbbc0438c
commit f2e899aaad
4 changed files with 73 additions and 1 deletions
@@ -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"] },
@@ -70,6 +70,12 @@ public static class OtOpcUaTelemetry
Meter.CreateCounter<long>("otopcua.redundancy.service_level_change", unit: "{change}",
description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state.");
/// <summary>Inbound operations denied by the Primary data-plane gate (archreview 03/S4) —
/// site=write|ack|alarm-emit, reason=secondary|detached|role-unknown.</summary>
public static readonly Counter<long> PrimaryGateDenied =
Meter.CreateCounter<long>("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 ----------------
/// <summary>
@@ -0,0 +1,28 @@
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// 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).
/// </summary>
public static class PrimaryGatePolicy
{
/// <summary>Decide whether this node should service a Primary-only data-plane operation.</summary>
/// <param name="localRole">This node's last-known redundancy role, or <c>null</c> when unknown
/// (no snapshot received yet, or the snapshot omitted this node).</param>
/// <param name="driverMemberCount">Count of Up cluster members carrying the <c>driver</c> role.</param>
/// <returns><c>true</c> to service as Primary; <c>false</c> to deny.</returns>
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
};
}
@@ -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;
/// <summary>
/// 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).
/// </summary>
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);
}