test(717): deferred-event boundary, ACL-filtered promotion refresh, native-ack refusal, effective-URI XML load; docs + T13 overlay
v2-ci / unit-tests (push) Has been cancelled

- Wire: an event reported while Active but deferred (#838) is delivered after demotion; the promotion refresh
  withholds an ACL-denied condition (positive control alongside); ConditionRefresh2 and model-change events on the
  Standby; a native Acknowledge is refused and never routed; Warm validates and advertises the EFFECTIVE
  ApplicationUri from an ApplicationConfigPath XML load.
- docs/Redundancy.md: Warm section, the Unset/None i=11314 behaviour (orphan readable by NodeId under Unset,
  deleted under None), instruments; OpcUaServer.md, Configuration.md, security.md, cluster-topology rule.
- docker-dev/docker-compose.warm.yml for the T13 gate (central pair, Mode=Warm).
- Plan: implementation status and the test gaps left for T13/follow-up.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 03:39:49 -05:00
parent 7829b937d1
commit f817c77e02
9 changed files with 357 additions and 14 deletions
@@ -44,6 +44,9 @@ internal sealed class ServerEventRig : IAsyncDisposable
public string EndpointUrl => $"opc.tcp://127.0.0.1:{_port}/OtOpcUa";
/// <summary>The SDK application configuration the host built (or loaded).</summary>
internal ApplicationConfiguration HostConfiguration => _host.ApplicationInstance!.ApplicationConfiguration!;
/// <summary>The configured <c>OpcUa:ApplicationUri</c> (the effective one: the rig loads no XML).</summary>
public string ApplicationUri => _hostOptions.ApplicationUri;
@@ -293,12 +293,45 @@ public sealed class WarmRedundancyWireTests
}
}
private static async Task<byte[]> ReadEventIdAsync(ISession session, CancellationToken ct)
private static async Task<byte[]> ReadEventIdAsync(ISession session, CancellationToken ct, bool native = false)
{
var eventId = await TranslateAsync(session, ConditionNodeId(session, native: false), BrowseNames.EventId, ct);
var eventId = await TranslateAsync(session, ConditionNodeId(session, native), BrowseNames.EventId, ct);
return ((ByteString)(await ReadAsync(session, eventId, ct)).WrappedValue.AsBoxedObject()!).ToArray()!;
}
/// <summary>Owner answer Q4 for a NATIVE condition: refused on the Warm Standby ahead of the native-ack Primary gate,
/// nothing routed to the driver; accepted once Active (positive control).</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task A_native_Acknowledge_on_a_Warm_standby_is_refused_and_never_routed(bool standby)
{
var ct = Deadline();
await using var rig = await StartModeAsync($"Warm717NativeAck{standby}", "Warm", ct);
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
rig.MaterialiseNative();
var routed = 0;
rig.Nm.NativeAlarmAckRouter = _ => Interlocked.Increment(ref routed);
var projection = rig.Server.CreateStandbyProjection()!;
Promote(projection);
rig.Project(native: true, ConditionMethodEventTests.PreState("Acknowledge", native: true) with { Message = "base" });
if (standby) Demote(projection);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var eventId = await ReadEventIdAsync(session, ct, native: true);
var call = ConditionMethodEventTests.CallAsync(session, ConditionNodeId(session, native: true), "Acknowledge", eventId, ct);
if (standby)
{
(await Should.ThrowAsync<ServiceResultException>(call)).StatusCode.ShouldBe(StatusCodes.BadInvalidState);
routed.ShouldBe(0);
}
else
{
await call;
routed.ShouldBe(1);
}
}
private static async Task<NodeId> TranslateAsync(ISession session, NodeId start, string path, CancellationToken ct)
{
var elements = path.Split('/').Select(name => new RelativePathElement
@@ -349,6 +382,16 @@ public sealed class WarmRedundancyWireTests
events.Has(item, "flash").ShouldBeFalse();
}
// Model-change events are not alarm events: still delivered on the Standby.
rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns);
await RecordedEvents.WaitUntilAsync(() => events.HasType(serverItem, ObjectTypeIds.GeneralModelChangeEventType));
// D5: ConditionRefresh2 on one item, then ConditionRefresh on the subscription, on the Standby: Start, the
// retained condition, End.
await session.CallAsync(ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh2, ct,
subscription.Id, folderItem.Status.Id);
await RecordedEvents.WaitUntilAsync(() => events.HasType(folderItem, ObjectTypeIds.RefreshEndEventType));
events.Has(folderItem, "while-standby").ShouldBeTrue("ConditionRefresh2 on the Standby returns the retained condition");
// D5: a client ConditionRefresh on the Standby returns Start, the retained condition, End.
await session.CallAsync(ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id);
await RecordedEvents.WaitUntilAsync(() => events.HasType(serverItem, ObjectTypeIds.RefreshEndEventType));
@@ -397,6 +440,123 @@ public sealed class WarmRedundancyWireTests
lock (routed) routed.ShouldHaveSingleItem().Operation.ShouldBe("Acknowledge");
}
/// <summary>Spec §4.5 boundary: an event REPORTED while Active but still deferred (#838) at demotion is delivered.</summary>
[Fact]
public async Task An_event_reported_while_Active_but_deferred_is_still_delivered_after_demotion()
{
var ct = Deadline();
await using var rig = await StartModeAsync("Warm717Deferred", "Warm", ct);
var projection = rig.Server.CreateStandbyProjection()!;
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
rig.MaterialiseNative();
Promote(projection);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var onA = RecordedEvents.AddEventItem(subscription, UnsNodeId(session, EqA));
await subscription.ApplyChangesAsync(ct);
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "seed"));
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "seed"));
using (var stall = NotifierStall.Hold(rig.Nm, EqA))
{
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 710, Message: "reported-active"));
rig.Nm.DeferredEventReports.ShouldBeGreaterThan(0, "the report must actually be deferred");
Demote(projection);
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 720, Message: "reported-standby"));
stall.Release();
}
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "reported-active"));
await Task.Delay(500, ct);
events.Has(onA, "reported-standby").ShouldBeFalse("a report made on the Standby is suppressed");
}
/// <summary>The promotion refresh goes through the per-item ACL decision: a condition the session may not see is
/// not replayed, while one it may see is (positive control).</summary>
[Fact]
public async Task The_promotion_refresh_withholds_an_ACL_denied_condition()
{
var ct = Deadline();
await using var rig = await StartModeAsync("Warm717AclRefresh", "Warm", ct);
var projection = rig.Server.CreateStandbyProjection()!;
rig.MaterialiseNative();
rig.MaterialiseScripted();
rig.Enforce(true, [Key()], EquipmentGrant(EqB,
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead));
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "native-visible"));
rig.Project(native: false, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 500, Message: "scripted-denied"));
using var session = await rig.ConnectAsync(anonymous: true, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
Promote(projection);
await RecordedEvents.WaitUntilAsync(() => events.HasType(serverItem, ObjectTypeIds.RefreshEndEventType));
events.Has(serverItem, "native-visible").ShouldBeTrue("positive control: a visible retained condition is replayed");
events.Has(serverItem, "scripted-denied").ShouldBeFalse("an ACL-denied condition is withheld from the promotion refresh");
}
/// <summary>T1: the redundancy check uses the EFFECTIVE ApplicationUri an ApplicationConfigPath XML load produces, not
/// the options value (c7787): listing the options URI as a peer is fine, listing the XML URI fails start.</summary>
[Fact]
public async Task The_effective_application_uri_from_a_loaded_xml_configuration_is_what_Warm_validates_and_advertises()
{
var ct = Deadline();
var seed = await ServerEventRig.StartAsync("Warm717XmlSeed", encrypted: false, ct);
var saved = seed.HostConfiguration;
var dir = Path.Combine(Path.GetTempPath(), $"warm717-xml-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
var file = Path.Combine(dir, "OtOpcUa.Config.xml");
var xmlUri = saved.ApplicationUri;
try
{
var port = OpcUaHostStart.AllocateFreePort();
saved.ServerConfiguration!.BaseAddresses = [$"opc.tcp://127.0.0.1:{port}/OtOpcUa"];
saved.SaveToFile(file);
OpcUaApplicationHostOptions Options(string peer) => new()
{
ApplicationName = saved.ApplicationName ?? "OtOpcUa.Warm717Xml",
ApplicationUri = "urn:OtOpcUa:options-value",
ApplicationConfigPath = file,
PublicHostname = "127.0.0.1",
OpcUaPort = port,
PkiStoreRoot = dir,
AutoAcceptUntrustedClientCertificates = true,
PeerApplicationUris = [peer],
Redundancy = new OpcUaRedundancyOptions { Mode = "Warm" },
};
var refused = new OpcUaApplicationHost(Options(xmlUri!),
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
(await Should.ThrowAsync<InvalidOperationException>(refused.StartAsync(new OtOpcUaSdkServer(), ct)))
.Message.ShouldContain(xmlUri!);
await using var host = new OpcUaApplicationHost(Options("urn:OtOpcUa:options-value"),
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
var server = new OtOpcUaSdkServer();
await host.StartAsync(server, ct);
var redundancy = server.CurrentInstance!.ServerObject.ServerRedundancy.ShouldBeOfType<NonTransparentRedundancyState>();
var uris = redundancy.ServerUriArray!.Value.ToArray()!;
uris.ShouldBe(new[] { xmlUri!, "urn:OtOpcUa:options-value" }, "self is the effective (XML) URI");
}
finally
{
// The seed stays up until here: the saved XML points at its PKI store (and certificate thumbprint).
await seed.DisposeAsync();
try { Directory.Delete(dir, recursive: true); } catch (IOException) { }
}
}
// ------------------------------------------------------------------ T8 live items on transition
[Fact]