docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
This commit is contained in:
Joseph Doherty
2026-07-07 12:38:39 -04:00
parent 384dbd7d36
commit 9cad9ed0fc
375 changed files with 1899 additions and 2493 deletions
@@ -4,8 +4,8 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Tri-state result of an <see cref="IPermissionEvaluator.Authorize"/> call, per decision
/// #149. Phase 6.2 only produces <see cref="AuthorizationVerdict.Allow"/> and
/// Tri-state result of an <see cref="IPermissionEvaluator.Authorize"/> call.
/// Phase 6.2 only produces <see cref="AuthorizationVerdict.Allow"/> and
/// <see cref="AuthorizationVerdict.NotGranted"/>; the <see cref="AuthorizationVerdict.Denied"/>
/// variant exists in the model so v2.1 Explicit Deny lands without an API break. Provenance
/// carries the matched grants (or empty when not granted) for audit + the Admin UI "Probe
@@ -19,6 +19,7 @@ public sealed record AuthorizationDecision(
public bool IsAllowed => Verdict == AuthorizationVerdict.Allow;
/// <summary>Convenience constructor for the common "no grants matched" outcome.</summary>
/// <returns>An authorization decision with the <see cref="AuthorizationVerdict.NotGranted"/> verdict.</returns>
public static AuthorizationDecision NotGranted() => new(AuthorizationVerdict.NotGranted, []);
/// <summary>Allow with the list of grants that matched.</summary>
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <remarks>
/// Data-plane only. Reads <c>NodeAcl</c> rows joined against the session's resolved LDAP
/// groups (via <see cref="UserAuthorizationState"/>). Must not depend on the control-plane
/// admin-role mapping table per decision #150 — the two concerns share zero runtime code.
/// admin-role mapping table — the two concerns share zero runtime code.
/// </remarks>
public interface IPermissionEvaluator
{
@@ -22,5 +22,6 @@ public interface IPermissionEvaluator
/// <param name="session">The user session containing resolved LDAP groups and roles.</param>
/// <param name="operation">The OPC UA operation being requested.</param>
/// <param name="scope">The node address scope being accessed.</param>
/// <returns>An authorization decision indicating whether the operation is allowed.</returns>
AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope);
}
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// to <see cref="IPermissionEvaluator"/> which walks the matching trie path.
/// </summary>
/// <remarks>
/// <para>Per decision #129 and the Phase 6.2 Stream B plan the hierarchy is
/// <para>Per the Phase 6.2 Stream B plan the hierarchy is
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> for all
/// (Equipment-kind) namespaces. Galaxy is a standard Equipment-kind driver, so Galaxy
/// points are ordinary equipment tags that resolve through this same walk.</para>
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// requested <see cref="Abstractions.OpcUaOperation"/>.
/// </summary>
/// <remarks>
/// Per decision #129 (additive grants, no explicit Deny in v2.0) the walk is pure union:
/// The walk is pure union (additive grants; no explicit Deny in v2.0):
/// encountering a grant at any level contributes its flags, never revokes them. A grant at
/// the Cluster root therefore cascades to every tag below it; a grant at a deep equipment
/// leaf is visible only on that equipment subtree.
@@ -33,6 +33,7 @@ public sealed class PermissionTrie
/// </summary>
/// <param name="scope">The node scope to match permissions for.</param>
/// <param name="ldapGroups">The user's LDAP group memberships.</param>
/// <returns>The matched grants accumulated across every trie level visited for the scope.</returns>
public IReadOnlyList<MatchedGrant> CollectMatches(NodeScope scope, IEnumerable<string> ldapGroups)
{
ArgumentNullException.ThrowIfNull(scope);
@@ -37,10 +37,11 @@ public static class PermissionTrieBuilder
/// <param name="diagnostic">
/// Optional callback invoked when a sub-cluster row's <c>ScopeId</c> cannot be located
/// in <paramref name="scopePaths"/>. Production callers should wire a logger here so
/// orphaned grants surface — silently dropping them under the wrong trie level was the
/// Core-011 production hazard. The callback fires only when <paramref name="scopePaths"/>
/// orphaned grants surface — silently dropping them under the wrong trie level was a
/// production hazard. The callback fires only when <paramref name="scopePaths"/>
/// is non-null (a null lookup is the explicit deterministic-test fallback mode).
/// </param>
/// <returns>The built <see cref="PermissionTrie"/> for the cluster + generation.</returns>
public static PermissionTrie Build(
string clusterId,
long generationId,
@@ -128,8 +129,7 @@ public sealed record NodeAclPath(IReadOnlyList<string> Segments);
/// <summary>
/// Diagnostic emitted by <see cref="PermissionTrieBuilder.Build"/> when a row could not be
/// placed at its structurally-correct trie node. Production callers should log these so
/// orphaned grants surface instead of being silently dropped under an unreachable node
/// (Core-011).
/// orphaned grants surface instead of being silently dropped under an unreachable node.
/// </summary>
/// <param name="NodeAclId">The offending row's logical id.</param>
/// <param name="ScopeKind">The row's <see cref="NodeAclScopeKind"/>.</param>
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <see cref="Install(PermissionTrie)"/>.
/// </summary>
/// <remarks>
/// Per decision #148 and Phase 6.2 Stream B.4 the cache is generation-sealed: once a
/// Per Phase 6.2 Stream B.4 the cache is generation-sealed: once a
/// trie is installed for <c>(ClusterId, GenerationId)</c> the entry is immutable. When a
/// new generation publishes, the caller calls <see cref="Install"/> with the new trie
/// + the cache atomically updates its "current generation" pointer for that cluster.
@@ -34,6 +34,7 @@ public sealed class PermissionTrieCache
/// <summary>Get the current-generation trie for a cluster; null when nothing installed.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <returns>The current trie for the cluster, or null when nothing is installed.</returns>
public PermissionTrie? GetTrie(string clusterId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
@@ -43,6 +44,7 @@ public sealed class PermissionTrieCache
/// <summary>Get a specific (cluster, generation) trie; null if that pair isn't cached.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <param name="generationId">The generation identifier.</param>
/// <returns>The trie for that cluster and generation, or null if that pair isn't cached.</returns>
public PermissionTrie? GetTrie(string clusterId, long generationId)
{
if (!_byCluster.TryGetValue(clusterId, out var entry)) return null;
@@ -51,6 +53,7 @@ public sealed class PermissionTrieCache
/// <summary>The generation id the <see cref="GetTrie(string)"/> shortcut currently serves for a cluster.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <returns>The current generation id for the cluster, or null if nothing is cached.</returns>
public long? CurrentGenerationId(string clusterId)
=> _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current.GenerationId : null;
@@ -111,11 +114,13 @@ public sealed class PermissionTrieCache
/// <summary>Creates a cluster entry from a single trie.</summary>
/// <param name="trie">The permission trie to create the entry from.</param>
/// <returns>A new cluster entry whose current trie and generation map both hold only <paramref name="trie"/>.</returns>
public static ClusterEntry FromSingle(PermissionTrie trie) =>
new(trie, new Dictionary<long, PermissionTrie> { [trie.GenerationId] = trie });
/// <summary>Creates a new entry with an additional trie, updating current if it's newer.</summary>
/// <param name="trie">The new permission trie to add.</param>
/// <returns>A new cluster entry with the trie merged in and the current pointer updated if needed.</returns>
public ClusterEntry WithAdditional(PermissionTrie trie)
{
var next = new Dictionary<long, PermissionTrie>(Tries) { [trie.GenerationId] = trie };
@@ -24,18 +24,12 @@ public sealed class TriePermissionEvaluator : IPermissionEvaluator
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>Authorizes an operation against the user's session and node scope.</summary>
/// <param name="session">The user's authorization session.</param>
/// <param name="operation">The OPC UA operation to authorize.</param>
/// <param name="scope">The target node scope.</param>
/// <returns>An authorization decision indicating whether the operation is allowed.</returns>
/// <inheritdoc />
public AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(scope);
// Decision #152 — beyond the staleness ceiling every call fails closed regardless of
// cache warmth elsewhere in the process.
if (session.IsStale(_timeProvider.GetUtcNow().UtcDateTime))
return AuthorizationDecision.NotGranted();
@@ -45,12 +39,6 @@ public sealed class TriePermissionEvaluator : IPermissionEvaluator
var trie = _cache.GetTrie(scope.ClusterId);
if (trie is null) return AuthorizationDecision.NotGranted();
// Decision #153 / Phase 6.2 adversarial-review item #3 (redundancy-safe invalidation):
// the GetTrie shortcut returns whatever generation the cache currently holds, which may
// have advanced past the generation this session was bound to (another node published).
// Evaluate against the session's *bound* generation so a grant added or removed in a
// newer generation cannot silently take effect mid-session, and so the provenance in the
// AuthorizationDecision reports the generation that actually produced the verdict.
if (trie.GenerationId != session.AuthGenerationId)
{
trie = _cache.GetTrie(scope.ClusterId, session.AuthGenerationId);
@@ -6,12 +6,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// the membership was resolved against, and the bounded freshness window.
/// </summary>
/// <remarks>
/// Per decision #151 the membership is bounded by <see cref="MembershipFreshnessInterval"/>
/// The membership is bounded by <see cref="MembershipFreshnessInterval"/>
/// (default 5 min). After that, the next hot-path authz call re-resolves LDAP group
/// memberships; failure to re-resolve (LDAP unreachable) flips the session to fail-closed
/// until a refresh succeeds.
///
/// Per decision #152 <see cref="AuthCacheMaxStaleness"/> (default 15 min) is separate from
/// <see cref="AuthCacheMaxStaleness"/> (default 15 min) is separate from
/// Phase 6.1's availability-oriented 24h cache — beyond this window the evaluator returns
/// <see cref="AuthorizationVerdict.NotGranted"/> regardless of config-cache warmth.
///
@@ -42,13 +42,13 @@ public sealed record UserAuthorizationState
/// Trie generation the session is currently bound to. When
/// <see cref="PermissionTrieCache"/> moves to a new generation, the session's
/// <c>(AuthGenerationId, MembershipVersion)</c> stamp no longer matches its
/// MonitoredItems and they re-evaluate on next publish (decision #153).
/// MonitoredItems and they re-evaluate on next publish.
/// </summary>
public required long AuthGenerationId { get; init; }
/// <summary>
/// Monotonic counter incremented every time membership is re-resolved. Combined with
/// <see cref="AuthGenerationId"/> into the subscription stamp per decision #153.
/// <see cref="AuthGenerationId"/> into the subscription stamp.
/// </summary>
public required long MembershipVersion { get; init; }
@@ -64,6 +64,7 @@ public sealed record UserAuthorizationState
/// whenever this is true.
/// </summary>
/// <param name="utcNow">The current UTC time.</param>
/// <returns><see langword="true"/> if the cached membership has exceeded the staleness ceiling.</returns>
public bool IsStale(DateTime utcNow) => utcNow - MembershipResolvedUtc > AuthCacheMaxStaleness;
/// <summary>
@@ -72,6 +73,7 @@ public sealed record UserAuthorizationState
/// call still evaluates against the cached memberships.
/// </summary>
/// <param name="utcNow">The current UTC time.</param>
/// <returns><see langword="true"/> if membership is past its freshness interval but not yet stale, signaling an async refresh is due.</returns>
public bool NeedsRefresh(DateTime utcNow) =>
!IsStale(utcNow) && utcNow - MembershipResolvedUtc > MembershipFreshnessInterval;
}
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
/// <c>DriverInstanceId</c> + <c>DriverConfig</c> JSON.
/// </summary>
/// <remarks>
/// Closes the gap surfaced by task #240 live smoke — DriverInstance rows in
/// Closes a gap where DriverInstance rows in
/// the central config DB had no path to materialise as registered <see cref="IDriver"/>
/// instances. The factory registry is the seam.
/// </remarks>
@@ -63,6 +63,7 @@ public sealed class DriverFactoryRegistry
/// missing-assembly deployment doesn't take down the whole server.
/// </summary>
/// <param name="driverType">The driver type to look up.</param>
/// <returns>The registered factory, or <see langword="null"/> if none is registered for <paramref name="driverType"/>.</returns>
public Func<string, string, IDriver>? TryGet(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
@@ -75,6 +76,7 @@ public sealed class DriverFactoryRegistry
/// case upstream; we don't double-surface that failure here.
/// </summary>
/// <param name="driverType">The driver type to look up.</param>
/// <returns>The recorded stability tier, or <see cref="DriverTier.A"/> if unknown.</returns>
public DriverTier GetTier(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
@@ -20,16 +20,13 @@ public sealed class DriverFactoryRegistryAdapter : IDriverFactory
_registry = registry;
}
/// <summary>Attempts to create a driver instance by type and configuration.</summary>
/// <param name="driverType">The driver type name.</param>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
var factory = _registry.TryGet(driverType);
return factory?.Invoke(driverInstanceId, driverConfigJson);
}
/// <summary>Gets the collection of supported driver type names.</summary>
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => _registry.RegisteredTypes;
}
@@ -3,8 +3,8 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
/// <summary>
/// Process-local registry + lifecycle manager for loaded <see cref="IDriver"/> instances
/// (decision #65). Phase 1 scaffold — per-process isolation for Tier C drivers (Galaxy, FOCAS)
/// Process-local registry + lifecycle manager for loaded <see cref="IDriver"/> instances.
/// Phase 1 scaffold — per-process isolation for Tier C drivers (Galaxy, FOCAS)
/// is implemented in Phase 2 via named-pipe RPC; this class handles in-process drivers today
/// and exposes the same registration interface so the Tier C wrapper can slot in later.
/// </summary>
@@ -21,6 +21,7 @@ public sealed class DriverHost : IAsyncDisposable
/// <summary>Gets the health status of a registered driver.</summary>
/// <param name="driverInstanceId">The driver instance identifier to query.</param>
/// <returns>The driver's health status, or <c>null</c> when not registered.</returns>
public DriverHealth? GetHealth(string driverInstanceId)
{
lock (_lock)
@@ -33,6 +34,7 @@ public sealed class DriverHost : IAsyncDisposable
/// startup. Returns null when the driver is not registered.
/// </summary>
/// <param name="driverInstanceId">The driver instance identifier to look up.</param>
/// <returns>The registered driver, or <c>null</c> when not registered.</returns>
public IDriver? GetDriver(string driverInstanceId)
{
lock (_lock)
@@ -47,6 +49,7 @@ public sealed class DriverHost : IAsyncDisposable
/// <param name="driver">The driver instance to register.</param>
/// <param name="driverConfigJson">The configuration JSON for the driver.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RegisterAsync(IDriver driver, string driverConfigJson, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(driver);
@@ -70,6 +73,7 @@ public sealed class DriverHost : IAsyncDisposable
/// <summary>Unregisters a driver and calls shutdown.</summary>
/// <param name="driverInstanceId">The driver instance identifier to unregister.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task UnregisterAsync(string driverInstanceId, CancellationToken ct)
{
IDriver? driver;
@@ -84,6 +88,7 @@ public sealed class DriverHost : IAsyncDisposable
}
/// <summary>Disposes the driver host and all registered drivers.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
List<IDriver> snapshot;
@@ -27,6 +27,7 @@ public static class DriverHealthReport
{
/// <summary>Compute the fleet-wide readiness verdict from per-driver states.</summary>
/// <param name="drivers">The list of per-driver health snapshots to aggregate.</param>
/// <returns>The fleet-wide readiness verdict.</returns>
public static ReadinessVerdict Aggregate(IReadOnlyList<DriverHealthSnapshot> drivers)
{
ArgumentNullException.ThrowIfNull(drivers);
@@ -54,6 +55,7 @@ public static class DriverHealthReport
/// return per the Stream C.1 state matrix.
/// </summary>
/// <param name="verdict">The readiness verdict to map to HTTP status.</param>
/// <returns>The HTTP status code the /readyz endpoint should return.</returns>
public static int HttpStatus(ReadinessVerdict verdict) => verdict switch
{
ReadinessVerdict.Healthy => 200,
@@ -11,7 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Observability;
/// automatically via the Serilog enricher chain.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/implementation/phase-6-1-resilience-and-observability.md</c> §Stream C.2.
/// The correlation ID should be the OPC UA <c>RequestHeader.RequestHandle</c> when in-flight;
/// otherwise a short random GUID. Callers supply whichever is available.
/// </remarks>
@@ -22,6 +21,7 @@ public static class LogContextEnricher
/// <param name="driverType">The driver type name.</param>
/// <param name="capability">The driver capability being invoked.</param>
/// <param name="correlationId">The correlation ID for tracing the call.</param>
/// <returns>A disposable scope that pops the pushed properties when disposed.</returns>
public static IDisposable Push(string driverInstanceId, string driverType, DriverCapability capability, string correlationId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
@@ -40,6 +40,7 @@ public static class LogContextEnricher
/// 12-hex-char slice of a GUID — long enough for log correlation, short enough to
/// scan visually.
/// </summary>
/// <returns>A 12-hex-character correlation ID.</returns>
public static string NewCorrelationId() => Guid.NewGuid().ToString("N")[..12];
private sealed class CompositeScope : IDisposable
@@ -10,12 +10,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
/// <c>UnsArea</c> / <c>UnsLine</c> / <c>Equipment</c> / <c>Tag</c> rows. Runs during
/// address-space build per <see cref="IDriver"/> whose
/// <c>Namespace.Kind = Equipment</c>; non-Equipment namespaces are
/// exempt per decision #120 and reach this walker only indirectly through
/// exempt and reach this walker only indirectly through
/// <see cref="ITagDiscovery.DiscoverAsync"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Composition strategy.</b> ADR-001 (2026-04-20) accepted Option A — Config
/// <b>Composition strategy.</b> Accepted Option A — Config
/// primary. The walker treats the supplied <see cref="EquipmentNamespaceContent"/>
/// snapshot as the authoritative published surface. Every Equipment row becomes a
/// folder node at the UNS level-5 segment; every <see cref="Tag"/> bound to an
@@ -26,12 +26,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
/// </para>
///
/// <para>
/// <b>Under each Equipment node.</b> Five identifier properties per decision #121
/// <b>Under each Equipment node.</b> Five identifier properties
/// (<c>EquipmentId</c>, <c>EquipmentUuid</c>, <c>MachineCode</c>, <c>ZTag</c>,
/// <c>SAPID</c>) are added as OPC UA properties — external systems (ERP, SAP PM)
/// resolve equipment by whichever identifier they natively use without a sidecar.
/// <see cref="IdentificationFolderBuilder.Build"/> materializes the OPC 40010
/// Identification sub-folder with the nine decision-#139 fields when at least one
/// Identification sub-folder with the nine fields when at least one
/// is non-null; when all nine are null the sub-folder is omitted rather than
/// appearing empty.
/// </para>
@@ -131,7 +131,7 @@ public static class EquipmentNodeWalker
}
/// <summary>
/// Adds the five operator-facing identifiers from decision #121 as OPC UA properties
/// Adds the five operator-facing identifiers as OPC UA properties
/// on the Equipment node. EquipmentId + EquipmentUuid are always populated;
/// MachineCode is required per <see cref="Equipment"/>; ZTag + SAPID are nullable in
/// the data model so they're skipped when null to avoid empty-string noise in the
@@ -152,7 +152,7 @@ public static class EquipmentNodeWalker
/// Emit a single Tag row as an <see cref="IAddressSpaceBuilder.Variable"/>. The driver
/// full reference lives in <c>Tag.TagConfig</c> (wire-level address, driver-specific
/// JSON blob); the variable node's data type derives from <c>Tag.DataType</c>.
/// Unreachable-address behavior per ADR-001 Option A: the variable is created; the
/// Unreachable-address behavior: the variable is created; the
/// driver's natural Read failure surfaces an OPC UA Bad status at runtime.
/// </summary>
private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag)
@@ -189,6 +189,7 @@ public static class EquipmentNodeWalker
/// wants an opaque non-JSON reference.
/// </remarks>
/// <param name="tagConfig">The tag configuration JSON or string.</param>
/// <returns>The extracted <c>FullName</c> value, or the raw <paramref name="tagConfig"/> if it can't be extracted.</returns>
internal static string ExtractFullName(string tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
@@ -208,7 +209,7 @@ public static class EquipmentNodeWalker
/// <summary>
/// Parse <see cref="Tag.DataType"/> (stored as the <see cref="DriverDataType"/> enum
/// name string, decision #138) into the enum value. Unknown names fall back to
/// name string) into the enum value. Unknown names fall back to
/// <see cref="DriverDataType.String"/> so a one-off driver-specific type doesn't
/// abort the whole walk; the underlying driver still sees the original TagConfig
/// address + can surface its own typed value via the OPC UA variant at read time.
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
/// against <c>CustomNodeManager2</c>.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #52 + #62 — Core owns the node tree, drivers stream
/// Per <c>docs/v2/plan.md</c> — Core owns the node tree, drivers stream
/// <c>Folder</c>/<c>Variable</c> calls, alarm-bearing variables are annotated via
/// <see cref="IVariableHandle.MarkAsAlarmCondition"/> and subsequent
/// <see cref="IAlarmSource.OnAlarmEvent"/> payloads route to the sink the builder returned.
@@ -41,7 +41,7 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
/// If called a second time (e.g. Galaxy redeploy via <c>IRediscoverable.OnRediscoveryNeeded</c>)
/// the previous alarm subscription is torn down and the sink registry is cleared before
/// re-walking, preventing double delivery of alarm transitions.
/// Exception isolation (per decision #12 — marking the driver's subtree Faulted while other
/// Exception isolation (marking the driver's subtree Faulted while other
/// drivers stay available) is the caller's responsibility; exceptions from
/// <see cref="ITagDiscovery.DiscoverAsync"/> propagate unhandled to the caller. The Server
/// project's <c>OpcUaApplicationHost.PopulateAddressSpaces</c> wraps this call in a per-driver
@@ -49,6 +49,7 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
/// </summary>
/// <param name="builder">The address space builder to populate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task BuildAddressSpaceAsync(IAddressSpaceBuilder builder, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -111,23 +112,15 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
IAddressSpaceBuilder inner,
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IAddressSpaceBuilder
{
/// <summary>Adds a folder to the address space.</summary>
/// <param name="browseName">The browse name of the folder node.</param>
/// <param name="displayName">The display name of the folder node.</param>
/// <inheritdoc />
public IAddressSpaceBuilder Folder(string browseName, string displayName)
=> new CapturingBuilder(inner.Folder(browseName, displayName), sinks);
/// <summary>Adds a variable to the address space.</summary>
/// <param name="browseName">The browse name of the variable node.</param>
/// <param name="displayName">The display name of the variable node.</param>
/// <param name="attributeInfo">Metadata describing the variable's data type and properties.</param>
/// <inheritdoc />
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
=> new CapturingHandle(inner.Variable(browseName, displayName, attributeInfo), sinks);
/// <summary>Adds a property to the address space.</summary>
/// <param name="browseName">The browse name of the property node.</param>
/// <param name="dataType">The OPC UA data type of the property.</param>
/// <param name="value">The initial value of the property, or null.</param>
/// <inheritdoc />
public void AddProperty(string browseName, DriverDataType dataType, object? value)
=> inner.AddProperty(browseName, dataType, value);
}
@@ -136,11 +129,10 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
IVariableHandle inner,
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IVariableHandle
{
/// <summary>Gets the full reference for the variable.</summary>
/// <inheritdoc />
public string FullReference => inner.FullReference;
/// <summary>Marks the variable as an alarm condition and registers its sink.</summary>
/// <param name="info">Configuration for the alarm condition.</param>
/// <inheritdoc />
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
var sink = inner.MarkAsAlarmCondition(info);
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
/// <summary>
/// Phase 6.4 Stream D: materializes the OPC 40010 Machinery companion-spec Identification
/// sub-folder under an Equipment node. Reads the nine decision-#139 columns off the
/// sub-folder under an Equipment node. Reads the nine documented columns off the
/// <see cref="Equipment"/> row and emits one property per non-null field.
/// </summary>
/// <remarks>
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
/// grant gets BadUserAccessDenied on both the Equipment node + its Identification variables.
/// See <c>docs/v2/acl-design.md</c> §Identification cross-reference.</para>
///
/// <para>The nine fields per decision #139 are exposed exactly when they carry a non-null
/// <para>The nine fields are exposed exactly when they carry a non-null
/// value. A row with all nine null produces no Identification sub-folder at all — the
/// caller can use <see cref="HasAnyFields(Equipment)"/> to skip the Folder call entirely
/// and avoid a pointless empty folder appearing in browse trees.</para>
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
/// <see cref="IAlarmSource.AcknowledgeAsync"/>) through <see cref="CapabilityInvoker"/> so the
/// Phase 6.1 resilience pipeline runs — retry semantics match
/// <see cref="DriverCapability.AlarmSubscribe"/> (retries by default) and
/// <see cref="DriverCapability.AlarmAcknowledge"/> (does NOT retry per decision #143).
/// <see cref="DriverCapability.AlarmAcknowledge"/> (does NOT retry).
/// </summary>
/// <remarks>
/// <para>Multi-host dispatch: when the driver implements <see cref="IPerCallHostResolver"/>,
@@ -59,6 +59,7 @@ public sealed class AlarmSurfaceInvoker
/// </summary>
/// <param name="sourceNodeIds">The source node IDs to subscribe to.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>One subscription handle per resolved host.</returns>
public async Task<IReadOnlyList<IAlarmSubscriptionHandle>> SubscribeAsync(
IReadOnlyList<string> sourceNodeIds,
CancellationToken cancellationToken)
@@ -89,6 +90,7 @@ public sealed class AlarmSurfaceInvoker
/// </summary>
/// <param name="handle">The subscription handle to unsubscribe.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(handle);
@@ -105,11 +107,12 @@ public sealed class AlarmSurfaceInvoker
/// <summary>
/// Acknowledge alarms. Fans out by resolved host; each host's batch runs through the
/// AlarmAcknowledge pipeline (no-retry per decision #143 — an alarm-ack is not idempotent
/// AlarmAcknowledge pipeline (no-retry — an alarm-ack is not idempotent
/// at the plant-floor acknowledgement level even if the OPC UA spec permits re-issue).
/// </summary>
/// <param name="acknowledgements">The alarm acknowledgement requests.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
CancellationToken cancellationToken)
@@ -161,7 +164,7 @@ public sealed class AlarmSurfaceInvoker
public IAlarmSubscriptionHandle Inner { get; } = inner;
/// <summary>Gets the resolved host name.</summary>
public string Host { get; } = host;
/// <summary>Gets the diagnostic ID from the inner handle.</summary>
/// <inheritdoc />
public string DiagnosticId => Inner.DiagnosticId;
}
}
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
/// is process-singleton so all invokers share its cache.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decisions #143-144 and Phase 6.1 Stream A.3. The server's dispatch
/// The server's dispatch
/// layer routes every capability call (<c>IReadable.ReadAsync</c>, <c>IWritable.WriteAsync</c>,
/// <c>ITagDiscovery.DiscoverAsync</c>, <c>ISubscribable.SubscribeAsync/UnsubscribeAsync</c>,
/// <c>IHostConnectivityProbe</c> probe loop, <c>IAlarmSource.SubscribeAlarmsAsync/AcknowledgeAsync</c>,
@@ -58,6 +58,7 @@ public sealed class CapabilityInvoker
/// <param name="hostName">The host name for logging and status tracking.</param>
/// <param name="callSite">The async function to execute.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The result of the underlying driver call.</returns>
public async ValueTask<TResult> ExecuteAsync<TResult>(
DriverCapability capability,
string hostName,
@@ -86,6 +87,7 @@ public sealed class CapabilityInvoker
/// <param name="hostName">The host name for logging and status tracking.</param>
/// <param name="callSite">The async function to execute.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask ExecuteAsync(
DriverCapability capability,
string hostName,
@@ -112,8 +114,8 @@ public sealed class CapabilityInvoker
/// <summary>
/// Execute a <see cref="DriverCapability.Write"/> call honoring <see cref="WriteIdempotentAttribute"/>
/// semantics — if <paramref name="isIdempotent"/> is <c>false</c>, retries are disabled regardless
/// of the tag-level configuration (the pipeline for a non-idempotent write never retries per
/// decisions #44-45). If <c>true</c>, the call runs through the capability's pipeline which may
/// of the tag-level configuration (the pipeline for a non-idempotent write never retries).
/// If <c>true</c>, the call runs through the capability's pipeline which may
/// retry when the tier configuration permits.
/// </summary>
/// <typeparam name="TResult">Return type of the underlying driver call.</typeparam>
@@ -121,6 +123,7 @@ public sealed class CapabilityInvoker
/// <param name="isIdempotent">Whether the write operation is idempotent.</param>
/// <param name="callSite">The async function to execute.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The result of the underlying driver write call.</returns>
public async ValueTask<TResult> ExecuteWriteAsync<TResult>(
string hostName,
bool isIdempotent,
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
/// <summary>
/// Per-tier × per-capability resilience policy configuration for a driver instance.
/// Bound from <c>DriverInstance.ResilienceConfig</c> JSON (nullable column; null = tier defaults).
/// Per <c>docs/v2/plan.md</c> decisions #143 and #144.
/// Per <c>docs/v2/plan.md</c>.
/// </summary>
public sealed record DriverResilienceOptions
{
@@ -34,7 +34,7 @@ public sealed record DriverResilienceOptions
/// indefinitely unless a memory breach or operator action triggers a recycle. Only
/// respected for <see cref="DriverTier.C"/>; Tier A/B recycle would tear down every
/// OPC UA session, so the loader ignores non-null values for those tiers + logs a
/// warning (per decisions #74 / #145).
/// warning.
/// </summary>
public int? RecycleIntervalSeconds { get; init; }
@@ -68,7 +68,7 @@ public sealed record DriverResilienceOptions
}
/// <summary>
/// Per-tier per-capability default policy table, per decisions #143-144 and the Phase 6.1
/// Per-tier per-capability default policy table, per the Phase 6.1
/// Stream A.2 specification. Retries skipped on <see cref="DriverCapability.Write"/> and
/// <see cref="DriverCapability.AlarmAcknowledge"/> regardless of tier.
/// </summary>
@@ -119,6 +119,6 @@ public sealed record DriverResilienceOptions
/// <param name="RetryCount">Number of retry attempts after the first failure; zero = no retry.</param>
/// <param name="BreakerFailureThreshold">
/// Consecutive-failure count that opens the circuit breaker; zero = no breaker
/// (Tier C uses the supervisor's process-level breaker instead, per decision #68).
/// (Tier C uses the supervisor's process-level breaker instead).
/// </param>
public sealed record CapabilityPolicy(int TimeoutSeconds, int RetryCount, int BreakerFailureThreshold);
@@ -50,6 +50,7 @@ public static class DriverResilienceOptionsParser
/// <param name="tier">The driver tier for default resilience options.</param>
/// <param name="resilienceConfigJson">The optional JSON configuration string to parse.</param>
/// <param name="parseDiagnostic">An out parameter containing diagnostic information if parsing fails.</param>
/// <returns>The effective <see cref="DriverResilienceOptions"/>, layering any parsed overrides on top of the tier defaults.</returns>
public static DriverResilienceOptions ParseOrDefaults(
DriverTier tier,
string? resilienceConfigJson,
@@ -96,7 +97,7 @@ public static class DriverResilienceOptionsParser
// Scheduled recycle is Tier C only — reject a configured interval on Tier A/B as a
// misconfiguration surface rather than silently honouring it (recycling an in-process
// driver would kill every OPC UA session + every co-hosted driver, per decision #74).
// driver would kill every OPC UA session + every co-hosted driver).
int? recycleIntervalSeconds = null;
if (shape.RecycleIntervalSeconds is int secs)
{
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
/// driver cannot open the circuit breaker for healthy sibling hosts.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #144 (per-device isolation). Composition from outside-in:
/// Per-device isolation. Composition from outside-in:
/// <b>Timeout → Retry (when capability permits) → Circuit Breaker (when tier permits) → Bulkhead</b>.
///
/// <para>Pipeline resolution is lock-free on the hot path: the inner
@@ -54,6 +54,7 @@ public sealed class DriverResiliencePipelineBuilder
/// </param>
/// <param name="capability">Which capability surface is being called.</param>
/// <param name="options">Per-driver-instance options (tier + per-capability overrides).</param>
/// <returns>The cached or newly built resilience pipeline for the key.</returns>
public ResiliencePipeline GetOrCreate(
string driverInstanceId,
string hostName,
@@ -128,10 +128,12 @@ public sealed class DriverResilienceStatusTracker
/// <summary>Snapshot of a specific (instance, host) pair; null if no counters recorded yet.</summary>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="hostName">The host name.</param>
/// <returns>The current resilience status snapshot, or null if no counters have been recorded for this pair.</returns>
public ResilienceStatusSnapshot? TryGet(string driverInstanceId, string hostName) =>
_status.TryGetValue(new StatusKey(driverInstanceId, hostName), out var snapshot) ? snapshot : null;
/// <summary>Copy of every currently-tracked (instance, host, snapshot) triple. Safe under concurrent writes.</summary>
/// <returns>A snapshot list of all tracked (driver instance, host, resilience snapshot) triples.</returns>
public IReadOnlyList<(string DriverInstanceId, string HostName, ResilienceStatusSnapshot Snapshot)> Snapshot() =>
_status.Select(kvp => (kvp.Key.DriverInstanceId, kvp.Key.HostName, kvp.Value)).ToList();
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Stability;
/// <see cref="IDriverSupervisor"/> to restart the out-of-process Host.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decisions #74 and #145. Tier A/B hard-breach on an in-process
/// Per <c>docs/v2/plan.md</c>. Tier A/B hard-breach on an in-process
/// driver would kill every OPC UA session and every co-hosted driver, so for Tier A/B this
/// class logs a <b>promotion-to-Tier-C recommendation</b> and does NOT invoke any supervisor.
/// A future tier-migration workflow acts on the recommendation.
@@ -6,11 +6,11 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Stability;
/// Tier-agnostic memory-footprint tracker. Captures the post-initialize <b>baseline</b>
/// from the first samples after <c>IDriver.InitializeAsync</c>, then classifies each
/// subsequent sample against a hybrid soft/hard threshold per
/// <c>docs/v2/plan.md</c> decision #146 — <c>soft = max(multiplier × baseline, baseline + floor)</c>,
/// <c>docs/v2/plan.md</c> — <c>soft = max(multiplier × baseline, baseline + floor)</c>,
/// <c>hard = 2 × soft</c>.
/// </summary>
/// <remarks>
/// <para>Per decision #145, this tracker <b>never kills a process</b>. Soft and hard breaches
/// <para>This tracker <b>never kills a process</b>. Soft and hard breaches
/// log + surface to the Admin UI via <c>DriverInstanceResilienceStatus</c>. The matching
/// process-level recycle protection lives in a separate <c>MemoryRecycle</c> that activates
/// for Tier C drivers only (where the driver runs out-of-process behind a supervisor that
@@ -31,8 +31,9 @@ public sealed class MemoryTracking
private TrackingPhase _phase = TrackingPhase.WarmingUp;
private DateTime? _warmupStartUtc;
/// <summary>Tier-default multiplier/floor constants per decision #146.</summary>
/// <summary>Tier-default multiplier/floor constants.</summary>
/// <param name="tier">The driver tier.</param>
/// <returns>The multiplier and floor-bytes constants for the tier.</returns>
public static (int Multiplier, long FloorBytes) GetTierConstants(DriverTier tier) => tier switch
{
DriverTier.A => (Multiplier: 3, FloorBytes: 50L * 1024 * 1024),
@@ -73,6 +74,7 @@ public sealed class MemoryTracking
/// </summary>
/// <param name="footprintBytes">The current memory footprint in bytes.</param>
/// <param name="utcNow">The current UTC time.</param>
/// <returns>The tracking action the caller should surface for this sample.</returns>
public MemoryTrackingAction Sample(long footprintBytes, DateTime utcNow)
{
if (_phase == TrackingPhase.WarmingUp)
@@ -136,7 +138,7 @@ public enum MemoryTrackingAction
/// <summary>
/// ≥ hard threshold. Log + surface + (Tier C only, via <c>MemoryRecycle</c>) request
/// process recycle via the driver supervisor. Tier A/B breach never invokes any
/// kill path per decisions #145 and #74.
/// kill path.
/// </summary>
HardBreach,
}
@@ -4,7 +4,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Stability;
/// <summary>
/// Tier C opt-in periodic-recycle driver per <c>docs/v2/plan.md</c> decision #67.
/// Tier C opt-in periodic-recycle driver.
/// A tick method advanced by the caller (fed by a background timer in prod; by test clock
/// in unit tests) decides whether the configured interval has elapsed and, if so, drives the
/// supplied <see cref="IDriverSupervisor"/> to recycle the Host.
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Stability;
/// <summary>
/// Demand-aware driver-wedge detector per <c>docs/v2/plan.md</c> decision #147.
/// Demand-aware driver-wedge detector per <c>docs/v2/plan.md</c>.
/// Flips a driver to <see cref="WedgeVerdict.Faulted"/> only when BOTH of the following hold:
/// (a) there is pending work outstanding, AND (b) no progress has been observed for longer
/// than <see cref="Threshold"/>. Idle drivers, write-only burst drivers, and subscription-only