using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Core.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Server.Security; /// /// Builds the path index consumed by /// from a Config-DB snapshot of a single published generation. Runs once per generation /// (or on every generation change) at the Server bootstrap layer; the produced index is /// immutable + hot-path readable per ADR-001 Task B. /// /// /// The index key is the driver-side full reference (Tag.TagConfig) — the same /// string the dispatch layer passes to . The value /// is a with every UNS level populated: /// ClusterId / NamespaceId / UnsAreaId / UnsLineId / EquipmentId / TagId. Tag rows /// with null EquipmentId (SystemPlatform-namespace Galaxy tags per decision #120) /// are excluded from the index — the cluster-only fallback path in the resolver handles /// them without needing an index entry. /// /// Duplicate keys are not expected but would be indicative of corrupt data — the /// builder throws on collision so a config drift /// surfaces at bootstrap instead of producing silently-last-wins scopes at dispatch. /// public static class ScopePathIndexBuilder { /// /// Build a fullReference → NodeScope index from the four Config-DB collections for a /// single namespace. Callers must filter inputs to a single /// + the same upstream. /// /// Owning cluster — populates . /// Owning namespace — populates . /// Pre-loaded rows for the namespace. public static IReadOnlyDictionary Build( string clusterId, string namespaceId, EquipmentNamespaceContent content) { ArgumentException.ThrowIfNullOrWhiteSpace(clusterId); ArgumentException.ThrowIfNullOrWhiteSpace(namespaceId); ArgumentNullException.ThrowIfNull(content); var areaByLine = content.Lines.ToDictionary(l => l.UnsLineId, l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase); var lineByEquipment = content.Equipment.ToDictionary(e => e.EquipmentId, e => e.UnsLineId, StringComparer.OrdinalIgnoreCase); var index = new Dictionary(StringComparer.Ordinal); foreach (var tag in content.Tags) { // Null EquipmentId = SystemPlatform-namespace tag per decision #110 — skip; the // cluster-only resolver fallback handles those without needing an index entry. if (string.IsNullOrEmpty(tag.EquipmentId)) continue; // Broken FK — Tag references a missing Equipment row. Skip rather than crash; // sp_ValidateDraft should have caught this at publish, so any drift here is // unexpected but non-fatal. if (!lineByEquipment.TryGetValue(tag.EquipmentId, out var lineId)) continue; if (!areaByLine.TryGetValue(lineId, out var areaId)) continue; var scope = new NodeScope { ClusterId = clusterId, NamespaceId = namespaceId, UnsAreaId = areaId, UnsLineId = lineId, EquipmentId = tag.EquipmentId, TagId = tag.TagConfig, Kind = NodeHierarchyKind.Equipment, }; if (!index.TryAdd(tag.TagConfig, scope)) throw new InvalidOperationException( $"Duplicate fullReference '{tag.TagConfig}' in Equipment namespace '{namespaceId}'. " + "Config data is corrupt — two Tag rows produced the same wire-level address."); } return index; } }