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
@@ -0,0 +1,161 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// The four fields a calc tag's <c>TagConfig</c> blob carries, as
/// <see cref="CalculationTagDefinitionFactory.TryReadTagConfig"/> read them. Deliberately not the
/// driver's <c>CalculationTagDefinition</c>: that record additionally carries the tag's RawPath identity
/// and the dependency refs extracted from the script source, neither of which is in the blob.
/// </summary>
/// <param name="ScriptId">Logical FK to the shared <c>Script</c> entity that computes the tag; never blank.</param>
/// <param name="ScriptSource">The resolved C# script source; empty when the blob carried none.</param>
/// <param name="ChangeTriggered">Re-evaluate whenever a declared dependency changes (default <see langword="true"/>).</param>
/// <param name="TimerIntervalMs">Periodic re-evaluation cadence in ms, or <see langword="null"/> for change-trigger only.</param>
public readonly record struct CalculationTagConfigFields(
string ScriptId,
string ScriptSource,
bool ChangeTriggered,
int? TimerIntervalMs);
/// <summary>
/// The authored-raw-tag reader for the <c>Calculation</c> pseudo-driver: one authority, consumed by both
/// the runtime (<c>CalculationTagDefinition.TryFromTagConfig</c>, which adds the RawPath identity and the
/// script's dependency refs on top) and the deploy-time TagConfig gate (<see cref="Inspect"/>,
/// deferment-register G-7).
/// <para>
/// <b>The JSON reading moved here from the driver; the definition-building did not.</b> Building a
/// <c>CalculationTagDefinition</c> needs <c>EquipmentScriptPaths.ExtractDependencyRefs</c>, which
/// lives in Commons and is genuinely runtime infrastructure — dragging it into a Contracts leaf to
/// co-locate the whole thing would make every consumer of this gate carry the scripting stack. The
/// split is at the blob boundary: everything the <i>blob</i> says is read here, once.
/// </para>
/// <para>
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryReadTagConfig"/>.</b> It reports
/// only two things: a blob the read REJECTS (which the driver skips as "not a calc tag"), and fields
/// that are present but in a shape the lenient read silently discards — <c>changeTriggered</c> that
/// is not a JSON boolean (read as <see langword="true"/>) and <c>timerIntervalMs</c> that is not a
/// positive JSON integer (read as no timer). Both are the sanctioned "present-but-invalid, silently
/// defaulted" warning class.
/// </para>
/// <para>
/// <b>What it deliberately does NOT warn about:</b> a tag with no trigger at all
/// (<c>changeTriggered:false</c> and no timer). The AdminUI's <c>CalculationTagConfigModel.Validate</c>
/// rejects that shape and the runtime accepts it, and this gate follows the <i>runtime</i> — it may
/// never call a blob the driver serves invalid, because the deploy gate can be configured to turn its
/// warnings into blocking errors. The same reasoning covers a sub-50 ms timer interval, which the
/// editor refuses and the driver honours.
/// </para>
/// </summary>
public static class CalculationTagDefinitionFactory
{
/// <summary>
/// Reads a calc tag's <c>TagConfig</c> blob
/// (<c>{ "scriptId", "scriptSource", "changeTriggered", "timerIntervalMs" }</c>). Returns
/// <see langword="false"/> — never throws — only when the blob is unreadable, is not a JSON object,
/// or names no <c>scriptId</c>: a tag with no identifiable script cannot be a calc tag. A present
/// <c>scriptId</c> with an empty/absent <c>scriptSource</c> still reads (the driver logs it and the
/// tag never computes).
/// </summary>
/// <param name="tagConfig">The calc tag's schemaless <c>TagConfig</c> JSON.</param>
/// <param name="fields">The read fields when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the blob names a script.</returns>
public static bool TryReadTagConfig(string tagConfig, out CalculationTagConfigFields fields)
{
fields = default;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
var scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String
? sidEl.GetString() : null;
if (string.IsNullOrWhiteSpace(scriptId)) return false;
var 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.
var changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl)
&& ctEl.ValueKind == JsonValueKind.False);
var timerMs = root.TryGetProperty("timerIntervalMs", out var tEl)
&& tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0
? ms : (int?)null;
fields = new CalculationTagConfigFields(scriptId!, scriptSource, changeTriggered, timerMs);
return true;
}
catch (JsonException)
{
return false;
}
}
/// <summary>
/// Deploy-time inspection of a calc tag's <c>TagConfig</c> blob. Warns for a blob
/// <see cref="TryReadTagConfig"/> rejects (unparseable JSON, a non-object root, or no usable
/// <c>scriptId</c>), and for a present-but-discarded <c>changeTriggered</c> / <c>timerIntervalMs</c>
/// — see the class remarks for what is deliberately NOT warned about. Empty when the blob is clean,
/// blank, or not a JSON object at all. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
return warnings;
JsonDocument doc;
try
{
doc = JsonDocument.Parse(reference);
}
catch (JsonException)
{
warnings.Add("Calculation TagConfig is not valid JSON — the driver will skip this tag and it will never compute.");
return warnings;
}
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("Calculation TagConfig root is not a JSON object — the driver will skip this tag and it will never compute.");
return warnings;
}
if (!TryReadTagConfig(reference, out _))
{
warnings.Add(
"Calculation TagConfig has no usable 'scriptId' (a non-blank string) — the driver does not "
+ "treat this as a calc tag, skips it, and it never computes.");
}
// Present-but-discarded fields. The read is lenient here by design, so these say what was
// IGNORED — they never claim the tag is invalid.
if (root.TryGetProperty("changeTriggered", out var ct)
&& ct.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
warnings.Add(
$"value '{ct.GetRawText()}' for 'changeTriggered' is not a JSON boolean; anything but an "
+ "explicit false is read as TRUE, so this tag is change-triggered whatever the value looks like.");
}
if (root.TryGetProperty("timerIntervalMs", out var timer)
&& !(timer.ValueKind == JsonValueKind.Number && timer.TryGetInt32(out var ms) && ms > 0))
{
warnings.Add(
$"value '{timer.GetRawText()}' for 'timerIntervalMs' is not a positive whole number of "
+ "milliseconds; it is discarded and the tag gets NO periodic re-evaluation.");
}
}
return warnings;
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Calculation</RootNamespace>
</PropertyGroup>
<!-- The calc-tag TagConfig reader, shared by the driver (which turns it into a
CalculationTagDefinition) and the deploy-time TagConfig gate (G-7), mirroring
Driver.MTConnect.Contracts / Driver.Modbus.Contracts.
Deliberately reference-free, unlike its siblings: a calc tag's blob is
{ scriptId, scriptSource, changeTriggered, timerIntervalMs } — strings, a bool and an int, with
no DriverDataType or other Core.Abstractions type in it. Carrying the leaf reference anyway
would be dead weight that reads as a dependency. Add it the moment a field needs it. -->
</Project>