v3 B1-WP4/WP5: pipeline rewire to greenfield schema + dark address space

- ConfigComposer: snapshot v3 tables (RawFolders/TagGroups/UnsTagReferences),
  drop the retired Namespaces snapshot; RevisionHash follows the new blob.
- DeploymentArtifact: compute each raw tag's RawPath (RawFolder->Driver->Device->
  TagGroup ancestry via shared RawPathResolver); per-driver RawTags + merged
  DriverConfig+DeviceConfig config injected into DriverInstanceSpec via
  DriverDeviceConfigMerger; ParseComposition emits an empty EquipmentTags set
  (DARK until Batch 4); EquipmentNode driver/device hooks always null; cluster
  scoping attributes equipment by UNS line only.
- AddressSpaceComposer: rewritten to the v3 shape (no Namespace/NamespaceKind,
  no dropped Tag/Equipment fields); emits empty EquipmentTags (parity mirror).
- DriverHostActor: fan-out/write map tuple member FullName -> RawPath.
- Commons: new RawPathResolver (shared identity authority) + DriverDeviceConfigMerger
  (single/multi-device endpoint+RawTags merge).
- DraftValidator: v3 rules — raw-name charset (RawPaths.ValidateSegment),
  historized effective-tagname <=255, UNS effective-leaf uniqueness across
  UnsTagReference/VirtualTag/ScriptedAlarm; DraftSnapshot(+Factory) carry the new tables.
- AdminOperationsActor: tag-config inspection resolves driver via Device (no
  EquipmentId/DriverInstanceId on Tag).
- Tests: RawPathResolver/merger unit tests (Commons.Tests, green) +
  DeploymentArtifact RawPath/dark test (Runtime.Tests, awaits WP6 corpus fixes).
This commit is contained in:
Joseph Doherty
2026-07-15 20:45:24 -04:00
parent 8e8dd2e824
commit e81ac352ed
12 changed files with 749 additions and 307 deletions
@@ -25,25 +25,36 @@ public sealed class DraftSnapshot
/// </summary>
public string? Site { get; init; }
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The old
// Namespaces list + namespace-binding validation are gone. WP4 (Wave C) owns the new v3
// rules (raw-name charset, historized-tagname length, UNS effective-leaf uniqueness).
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The Raw tree
// (RawFolders → DriverInstances → Devices → TagGroups → Tags) + the UNS projection
// (UnsTagReferences) feed the v3 validator rules (raw-name charset, historized-tagname length, UNS
// effective-leaf uniqueness).
/// <summary>Gets the list of Raw-tree folders (drive the raw-name charset rule + RawPath computation).</summary>
public IReadOnlyList<RawFolder> RawFolders { get; init; } = [];
/// <summary>Gets the list of driver instances.</summary>
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
/// <summary>Gets the list of devices.</summary>
public IReadOnlyList<Device> Devices { get; init; } = [];
/// <summary>Gets the list of tag-groups (drive the raw-name charset rule + RawPath computation).</summary>
public IReadOnlyList<TagGroup> TagGroups { get; init; } = [];
/// <summary>Gets the list of UNS areas.</summary>
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
/// <summary>Gets the list of UNS lines.</summary>
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
/// <summary>Gets the list of equipment.</summary>
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
/// <summary>Gets the list of tags.</summary>
/// <summary>Gets the list of raw tags.</summary>
public IReadOnlyList<Tag> Tags { get; init; } = [];
/// <summary>Equipment-bound VirtualTags (script-derived signals). Shares the equipment NodeId space
/// with Tags; the collision rule checks both.</summary>
/// <summary>Gets the UNS tag references (raw tag → equipment projection). Drives the UNS effective-leaf
/// uniqueness rule (effective name = DisplayNameOverride else the backing raw tag's Name).</summary>
public IReadOnlyList<UnsTagReference> UnsTagReferences { get; init; } = [];
/// <summary>Equipment-bound VirtualTags (script-derived signals). Part of the UNS effective-leaf
/// uniqueness set within an equipment (references + VirtualTags + ScriptedAlarms).</summary>
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
/// <summary>Gets the list of poll groups.</summary>
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
@@ -36,14 +36,20 @@ public static class DraftSnapshotFactory
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
// v3: Namespace entity retired — no namespace list to materialize.
// v3: Namespace entity retired — no namespace list to materialize. The Raw tree
// (RawFolders/TagGroups) + UnsTagReferences feed the v3 rules (charset, tagname length,
// effective-leaf uniqueness).
RawFolders = await db.RawFolders.AsNoTracking().ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
TagGroups = await db.TagGroups.AsNoTracking().ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations
@@ -32,33 +32,110 @@ public static class DraftValidator
ValidateEquipmentUuidImmutability(draft, errors);
ValidateReservationPreflight(draft, errors);
ValidateEquipmentIdDerivation(draft, errors);
ValidateNoEquipmentSignalNameCollision(draft, errors);
// v3 WP1 note: the retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
// and ValidateGalaxyTagFullName (blob-as-identity gone; Tags are raw-only, not equipment-bound)
// are deleted here. TODO(v3 WP4): add the new v3 rules — raw-name charset (no '/', no
// lead/trail whitespace) at every level, historized effective-tagname ≤ 255 chars, and UNS
// effective-leaf uniqueness across UnsTagReference/VirtualTag/ScriptedAlarm.
// v3 rules (WP4). The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
// and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
ValidateRawNameCharset(draft, errors);
ValidateHistorizedTagnameLength(draft, errors);
ValidateUnsEffectiveLeafUniqueness(draft, errors);
return errors;
}
private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List<ValidationError> errors)
/// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
{
// v3: Tags are raw-only (no EquipmentId), so the old Tag-vs-VirtualTag NodeId collision does
// not apply here. Until WP4 wires the full UNS effective-leaf rule (across UnsTagReference,
// VirtualTag, and ScriptedAlarm), enforce the still-valid invariant that a VirtualTag Name is
// unique within its owning equipment — the OPC UA NodeId key is "{EquipmentId}/{Name}".
var signals = draft.VirtualTags.Select(v => (Eq: v.EquipmentId, v.Name));
var folders = draft.RawFolders.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
var drivers = draft.DriverInstances.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
var devices = draft.Devices.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
var groups = draft.TagGroups.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
return new RawPathResolver(folders, drivers, devices, groups);
}
foreach (var g in signals.GroupBy(s => $"{s.Eq}/{s.Name}", StringComparer.Ordinal))
/// <summary>v3: every raw Name (RawFolder / DriverInstance / Device / TagGroup / Tag) must be a legal
/// RawPath segment — no <c>/</c>, no leading/trailing whitespace, non-empty — because <c>/</c> is the
/// RawPath separator and would corrupt the NodeId. Uses <see cref="RawPaths.ValidateSegment"/>, the same
/// charset authority the path builder enforces.</summary>
private static void ValidateRawNameCharset(DraftSnapshot draft, List<ValidationError> errors)
{
void Check(string kind, string id, string name)
{
var items = g.ToList();
if (items.Count <= 1) continue;
var f = items[0];
errors.Add(new("EquipmentSignalNameCollision",
$"{items.Count} signals collide on OPC UA NodeId '{g.Key}' (equipment '{f.Eq}', name '{f.Name}'); " +
"a VirtualTag Name must be unique within an equipment",
f.Eq));
var error = RawPaths.ValidateSegment(name);
if (error is not null)
errors.Add(new("RawNameInvalid", $"{kind} name '{name}' is not a legal RawPath segment: {error}", id));
}
foreach (var f in draft.RawFolders) Check("RawFolder", f.RawFolderId, f.Name);
foreach (var d in draft.DriverInstances) Check("DriverInstance", d.DriverInstanceId, d.Name);
foreach (var d in draft.Devices) Check("Device", d.DeviceId, d.Name);
foreach (var g in draft.TagGroups) Check("TagGroup", g.TagGroupId, g.Name);
foreach (var t in draft.Tags) Check("Tag", t.TagId, t.Name);
}
/// <summary>v3: a historized tag's EFFECTIVE historian tagname (the <c>historianTagname</c> override
/// else the tag's RawPath) must be ≤ 255 chars — the live-verified AVEVA historian limit (256 rejected).
/// A longer name is a deploy error telling the author to set a shorter <c>historianTagname</c> override,
/// never a silent truncation.</summary>
private static void ValidateHistorizedTagnameLength(DraftSnapshot draft, List<ValidationError> errors)
{
const int MaxHistorianTagname = 255;
var resolver = BuildRawPathResolver(draft);
foreach (var t in draft.Tags)
{
var intent = TagConfigIntent.Parse(t.TagConfig);
if (!intent.IsHistorized) continue;
// Effective tagname = explicit override else the computed RawPath (null when the chain is broken —
// a separate rule/charset check flags that; nothing to length-check here).
var effective = intent.HistorianTagname ?? resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
if (effective is null || effective.Length <= MaxHistorianTagname) continue;
errors.Add(new("HistorianTagnameTooLong",
$"tag '{t.TagId}' historized effective tagname is {effective.Length} chars (max {MaxHistorianTagname}); " +
"set a shorter 'historianTagname' override in its TagConfig",
t.TagId));
}
}
/// <summary>v3: within an equipment, the EFFECTIVE leaf name must be unique across its UnsTagReferences
/// (DisplayNameOverride else the backing raw tag's Name), its VirtualTags (Name), and its ScriptedAlarms
/// (Name) — the three share the equipment's UNS NodeId space (<c>{EquipmentId}/{EffectiveName}</c>).
/// Enforced here at the deploy gate (as well as at authoring) so a raw-tag rename that induces a clash the
/// authoring check never saw is caught before it ships.</summary>
private static void ValidateUnsEffectiveLeafUniqueness(DraftSnapshot draft, List<ValidationError> errors)
{
var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
// (EquipmentId, EffectiveName) → source descriptions, so a collision names both sources.
var byLeaf = new Dictionary<(string Eq, string Name), List<string>>();
void Add(string equipmentId, string? effectiveName, string source)
{
if (string.IsNullOrEmpty(effectiveName)) return;
var key = (equipmentId, effectiveName);
if (!byLeaf.TryGetValue(key, out var list)) byLeaf[key] = list = new List<string>();
list.Add(source);
}
foreach (var r in draft.UnsTagReferences)
{
var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
Add(r.EquipmentId, effective, $"reference '{r.UnsTagReferenceId}'");
}
foreach (var v in draft.VirtualTags)
Add(v.EquipmentId, v.Name, $"VirtualTag '{v.VirtualTagId}'");
foreach (var a in draft.ScriptedAlarms)
Add(a.EquipmentId, a.Name, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
foreach (var ((eq, name), sources) in byLeaf)
{
if (sources.Count <= 1) continue;
errors.Add(new("UnsEffectiveNameCollision",
$"{sources.Count} UNS signals collide on effective name '{name}' within equipment '{eq}': " +
string.Join(", ", sources) + "; effective names must be unique across references, VirtualTags, and ScriptedAlarms",
eq));
}
}