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
@@ -3250,7 +3250,7 @@ public sealed class MTConnectDriver
{
if (entry is null || string.IsNullOrWhiteSpace(entry.RawPath)) continue;
if (!TryReadRawTag(entry, out var dataItemId, out var authored))
if (!MTConnectTagDefinitionFactory.TryFromTagConfig(entry.TagConfig, out var dataItemId, out var authored))
{
_skippedTags.Skip(
entry.RawPath,
@@ -3392,71 +3392,6 @@ public sealed class MTConnectDriver
return map.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>
/// Reads one authored raw tag: the DataItem id it binds, and the definition its blob declares
/// (null when the blob declares no type and the caller must fall back).
/// </summary>
/// <returns>
/// <see langword="false"/> when the blob is unreadable, names no DataItem id, or carries a
/// <c>driverDataType</c> that is not a <see cref="DriverDataType"/> — a present-but-invalid
/// type rejects the tag rather than silently defaulting, which would publish the Agent's text
/// as a Good value of the wrong type (the sibling drivers' strict-enum posture).
/// </returns>
private static bool TryReadRawTag(
RawTagEntry entry, out string dataItemId, out MTConnectTagDefinition? authored)
{
dataItemId = string.Empty;
authored = null;
if (string.IsNullOrWhiteSpace(entry.TagConfig)) return false;
MTConnectRawTagConfigDto? blob;
try
{
blob = JsonSerializer.Deserialize<MTConnectRawTagConfigDto>(entry.TagConfig, JsonOptions);
}
catch (JsonException)
{
return false;
}
if (blob is null) return false;
var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address);
if (id is null) return false;
dataItemId = id;
// "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed
// MTConnectTagConfigModel writes) — see the DTO remarks.
var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType);
if (authoredType is null) return true;
// A digits-only type is rejected outright, BEFORE the parse: Enum.TryParse accepts "8" as
// readily as "Float64" and returns the member with that ordinal, so a numerically-serialized
// enum (the repo-wide authoring trap) would otherwise coerce every value of that tag to a
// type nobody chose, under Good quality. The AdminUI's MTConnectTagConfigModel.Validate
// refuses the same shape; this is the runtime half of that guard.
if (authoredType.All(char.IsAsciiDigit)
|| !Enum.TryParse<DriverDataType>(authoredType, ignoreCase: true, out var parsed)
|| !Enum.IsDefined(parsed))
{
return false;
}
authored = new MTConnectTagDefinition(
dataItemId,
parsed,
blob.IsArray ?? false,
blob.ArrayDim ?? 0,
blob.MtCategory,
blob.MtType,
blob.MtSubType,
blob.Units);
return true;
}
/// <summary>
/// The definition for a raw tag whose blob declared no type: the Agent's own <c>/probe</c>
/// declaration when the model is in hand, else <see cref="DriverDataType.String"/> — the one
@@ -3505,17 +3440,6 @@ public sealed class MTConnectDriver
return map.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>The first of <paramref name="candidates"/> that carries text, trimmed; else null.</summary>
private static string? FirstNonBlank(params string?[] candidates)
{
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
}
return null;
}
/// <summary>
/// Parses an enum authored as a <b>name</b>. Config surfaces serialize enums as names
/// project-wide; a numerically-serialized enum is the known trap that faults a driver at
@@ -3735,51 +3659,6 @@ public sealed class MTConnectDriver
public MTConnectWriteDto? Write { get; init; }
}
/// <summary>
/// A raw tag's <c>TagConfig</c> blob: the driver-specific address of one authored raw tag.
/// Every field is optional — the blob is operator-authorable and reaches the driver through
/// several authoring surfaces that populate different subsets of it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Three accepted spellings of the DataItem id</b>, tried in order:
/// <c>fullName</c> (the driver's own tag shape, and what the typed editor writes),
/// <c>dataItemId</c> (the protocol's own word for it, and the spelling an operator
/// hand-authoring the blob reaches for first), and <c>address</c> (what
/// <c>RawBrowseCommitMapper</c> writes for a driver with no typed editor of its own — the
/// shape every browse-committed MTConnect tag currently arrives in). Accepting all three
/// costs two dictionary probes; rejecting two of them would make a tag committed from the
/// address picker silently unreadable.
/// </para>
/// <para>
/// <b>Two accepted spellings of the coercion type</b>, likewise: <c>dataType</c> — what
/// the AdminUI's <c>MTConnectTagConfigModel</c> writes, and therefore what every tag
/// authored through the typed editor carries — and <c>driverDataType</c>, matching the
/// driver's own <c>tags[]</c> field name. Reading only one of them would make the editor
/// and the driver disagree about the type of every tag.
/// </para>
/// <para>
/// Kept separate from <see cref="MTConnectTagDto"/> rather than reusing it: that DTO is
/// the strict <c>tags[]</c> surface, where a missing <c>fullName</c> is a config error
/// worth failing the deployment over. A raw tag's blob is authored elsewhere and its
/// failure mode is per-tag, not per-driver.
/// </para>
/// </remarks>
private sealed class MTConnectRawTagConfigDto
{
public string? FullName { get; init; }
public string? DataItemId { get; init; }
public string? Address { get; init; }
public string? DriverDataType { get; init; }
public string? DataType { get; init; }
public bool? IsArray { get; init; }
public int? ArrayDim { get; init; }
public string? MtCategory { get; init; }
public string? MtType { get; init; }
public string? MtSubType { get; init; }
public string? Units { get; init; }
}
/// <summary>One authored tag. <c>DriverDataType</c> stays a string — see <see cref="ParseEnum{T}"/>.</summary>
private sealed class MTConnectTagDto
{