diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs
new file mode 100644
index 00000000..20bb263a
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs
@@ -0,0 +1,130 @@
+using System.Text.Json.Nodes;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
+
+/// Typed working model for an MTConnect tag's TagConfig JSON. The tag binds a single Agent
+/// DataItem by its id attribute ( — MTConnect is read-only, so
+/// there is no address encoding beyond the id); the remaining fields are probe-sourced display
+/// metadata plus an author-settable type override. Preserves unrecognised JSON keys across a
+/// load→save.
+///
+///
+/// /// come
+/// from the Agent's /probe response (see MTConnectTagDefinition in the driver's
+/// Contracts project) and are shown read-only by Task 18's editor — this model still round-trips
+/// them so a browse-commit that stamped them survives a manual edit of .
+/// / are pure author context (which physical
+/// device/component this DataItem belongs to); nothing downstream consumes them.
+///
+///
+/// is an override of the driver's own probe-based inference
+/// (MTConnectDataTypeInference.Infer, which the browse-commit path already applied when it
+/// wrote and this field). It is deliberately NOT recomputed here — this
+/// editor only re-serialises whatever the operator picks or the browse commit stamped, never a
+/// locally-reinvented inference that could disagree with the one true rule.
+///
+///
+public sealed class MTConnectTagConfigModel
+{
+ /// The MTConnect DataItem's id attribute — the driver's read/subscribe key. Required.
+ public string FullName { get; set; } = "";
+
+ /// Logical data type override for the DataItem's value. Defaults to
+ /// — MTConnect is weakly typed on the wire, so an unset override should never coerce to a numeric type
+ /// that can fail to parse (mirrors MTConnectDataTypeInference's own String-leaning default).
+ public DriverDataType DataType { get; set; } = DriverDataType.String;
+
+ /// The DataItem's probe-sourced category (SAMPLE/EVENT/CONDITION). Read-only display metadata.
+ public string? MtCategory { get; set; }
+
+ /// The DataItem's probe-sourced type (e.g. POSITION, EXECUTION). Read-only display metadata.
+ public string? MtType { get; set; }
+
+ /// The DataItem's probe-sourced subType (e.g. ACTUAL, COMMANDED). Read-only display metadata.
+ public string? MtSubType { get; set; }
+
+ /// The DataItem's probe-sourced units (e.g. MILLIMETER). Read-only display metadata.
+ public string? Units { get; set; }
+
+ /// Author-entered note identifying the owning MTConnect device. Pure authoring context; not
+ /// consumed by the driver or the runtime.
+ public string? MtDevice { get; set; }
+
+ /// Author-entered note identifying the owning MTConnect component. Pure authoring context; not
+ /// consumed by the driver or the runtime.
+ public string? MtComponent { get; set; }
+
+ // The as-loaded raw "dataType" JSON value, kept only to detect the ParseEnum numeric-text trap in
+ // Validate() below — Enum.TryParse (which TagConfigJson.GetEnum uses) silently accepts a quoted
+ // number ("8" -> Float64) exactly as readily as a name, so a config that was ever hand-authored or
+ // migrated with a numeric dataType would otherwise load into a *valid-looking* enum value with no
+ // signal that it isn't a name. ToJson() always re-writes DataType as a name, so this can only ever
+ // be non-null on the FIRST load of an already-poisoned config, never after a save through this model.
+ private string? _rawDataType;
+
+ private JsonObject _bag = new();
+
+ /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
+ /// every original key (so fields this editor doesn't expose survive a load→save).
+ /// The raw TagConfig JSON string, or null for a new/empty config.
+ /// The populated .
+ public static MTConnectTagConfigModel FromJson(string? json)
+ {
+ var o = TagConfigJson.ParseOrNew(json);
+ return new MTConnectTagConfigModel
+ {
+ FullName = TagConfigJson.GetString(o, "fullName") ?? "",
+ DataType = TagConfigJson.GetEnum(o, "dataType", DriverDataType.String),
+ MtCategory = TagConfigJson.GetString(o, "mtCategory"),
+ MtType = TagConfigJson.GetString(o, "mtType"),
+ MtSubType = TagConfigJson.GetString(o, "mtSubType"),
+ Units = TagConfigJson.GetString(o, "units"),
+ MtDevice = TagConfigJson.GetString(o, "mtDevice"),
+ MtComponent = TagConfigJson.GetString(o, "mtComponent"),
+ _rawDataType = TagConfigJson.GetString(o, "dataType"),
+ _bag = o,
+ };
+ }
+
+ /// Serialises this model back to a TagConfig JSON string over the preserved key bag.
+ /// dataType is always written as its enum NAME (never a bare number — see the
+ /// enum-serialization trap in the class remarks); the optional metadata keys are omitted when
+ /// blank rather than persisted as empty strings.
+ /// The serialised TagConfig JSON string.
+ public string ToJson()
+ {
+ TagConfigJson.Set(_bag, "fullName", FullName.Trim());
+ TagConfigJson.Set(_bag, "dataType", DataType);
+ TagConfigJson.Set(_bag, "mtCategory", Blank(MtCategory));
+ TagConfigJson.Set(_bag, "mtType", Blank(MtType));
+ TagConfigJson.Set(_bag, "mtSubType", Blank(MtSubType));
+ TagConfigJson.Set(_bag, "units", Blank(Units));
+ TagConfigJson.Set(_bag, "mtDevice", Blank(MtDevice));
+ TagConfigJson.Set(_bag, "mtComponent", Blank(MtComponent));
+ return TagConfigJson.Serialize(_bag);
+ }
+
+ /// Validation hook; returns an error message or null when the model is valid.
+ /// An error message describing the validation failure, or null when the model is valid.
+ public string? Validate()
+ {
+ if (string.IsNullOrWhiteSpace(FullName))
+ {
+ return "A DataItem id (fullName) is required.";
+ }
+
+ // See the _rawDataType remarks: a quoted number parses as a "valid" enum today but is never
+ // something this editor itself would have written, so surface it instead of silently accepting it.
+ if (_rawDataType is { Length: > 0 } raw && raw.All(char.IsAsciiDigit))
+ {
+ return $"dataType \"{raw}\" is a numeric value, not a named type — re-select a data type.";
+ }
+
+ return null;
+ }
+
+ // Normalises a blank/whitespace-only string to null so TagConfigJson.Set omits the key rather than
+ // persisting an empty string.
+ private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
index 01e0e8b3..a81f6b25 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
@@ -37,6 +37,7 @@
+