feat(drivers): Contracts-side TagConfig Inspect for Sql, MTConnect, Calculation and OpcUaClient

Deferment-register G-7: these four were validated by the AdminUI's TagConfigValidator at
authoring but by nothing at deploy, so config arriving by CSV import or API met no gate at
all. The deploy gate dispatches to a driver's *TagDefinitionFactory.Inspect in its Contracts
assembly, and none of the four had one.

Rather than mirror the AdminUI models (a mirror of an accept/reject boundary drifts silently,
with the gate describing a shape the driver no longer reads), each Inspect is built on the
driver's own runtime parse:

- Sql wraps the existing SqlEquipmentTagParser, which already lived in Sql.Contracts. The
  P3-deferred Query model stays refused; Inspect surfaces the refusal rather than weakening it.
- MTConnect's blob reader MOVED out of MTConnectDriver (it was private, with a private DTO)
  into MTConnectTagDefinitionFactory; the driver now calls it. Behaviour unchanged.
- OpcUaClient's nodeId read MOVED off NamespaceMap into OpcUaClientTagDefinitionFactory;
  NamespaceMap now calls it. Behaviour unchanged.
- Calculation gains a Contracts assembly it never had. Only the JSON reading moved there
  (CalculationTagDefinitionFactory.TryReadTagConfig); building a CalculationTagDefinition
  still needs EquipmentScriptPaths from Commons and stays in the driver, so the split is at
  the blob boundary. The new project is deliberately reference-free — a calc blob is strings,
  a bool and an int, with no Core.Abstractions type in it.

Each Inspect runs the parse FIRST and returns empty on success, so "never stricter than the
runtime" is structural rather than a promise: a warning is only reachable for a blob the
driver would have refused and then skipped silently. Calculation additionally reports two
present-but-silently-discarded fields (a non-boolean changeTriggered reads as true; a
non-positive-integer timerIntervalMs is dropped), and deliberately does NOT warn about a
trigger-less tag or a sub-50ms timer — the editor refuses both, the runtime honours both, and
the deploy gate follows the runtime.
This commit is contained in:
Joseph Doherty
2026-08-09 21:14:32 -04:00
parent 0125634400
commit 2947a2b9e2
10 changed files with 709 additions and 185 deletions
@@ -1,4 +1,3 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
@@ -40,45 +39,20 @@ public sealed record CalculationTagDefinition(
public static bool TryFromTagConfig(string tagConfig, string rawPath, out CalculationTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
string? scriptId;
string scriptSource;
bool changeTriggered;
int? timerMs;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
// The blob read itself lives in Driver.Calculation.Contracts so the deploy-time TagConfig gate
// (EquipmentTagConfigInspector) and this mapping cannot disagree about what a calc tag's blob says
// (G-7). Everything below is what the BLOB does not carry: the RawPath identity and the dependency
// refs extracted from the script source.
if (!CalculationTagDefinitionFactory.TryReadTagConfig(tagConfig, out var fields)) return false;
scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String
? sidEl.GetString() : null;
if (string.IsNullOrWhiteSpace(scriptId)) return false;
scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
? srcEl.GetString() ?? string.Empty : string.Empty;
// changeTriggered defaults to true (mini-design §2) — only an explicit false disables it.
changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl)
&& ctEl.ValueKind == JsonValueKind.False);
timerMs = root.TryGetProperty("timerIntervalMs", out var tEl)
&& tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0
? ms : null;
}
catch (JsonException)
{
return false;
}
var deps = EquipmentScriptPaths.ExtractDependencyRefs(scriptSource);
var deps = EquipmentScriptPaths.ExtractDependencyRefs(fields.ScriptSource);
def = new CalculationTagDefinition(
RawPath: rawPath,
ScriptId: scriptId!,
ScriptSource: scriptSource,
ChangeTriggered: changeTriggered,
TimerInterval: timerMs is { } m ? TimeSpan.FromMilliseconds(m) : null,
ScriptId: fields.ScriptId,
ScriptSource: fields.ScriptSource,
ChangeTriggered: fields.ChangeTriggered,
TimerInterval: fields.TimerIntervalMs is { } m ? TimeSpan.FromMilliseconds(m) : null,
DependencyRefs: deps);
return true;
}
@@ -19,6 +19,9 @@
for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext +
PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions
owns DataValueSnapshot. -->
<!-- The calc tag's TagConfig reader (CalculationTagDefinitionFactory), shared with the deploy-time
TagConfig gate so the two cannot drift (G-7). -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>