Adds the driver-expansion program design (umbrella: universal Discover-backed browser + MTConnect, MQTT/Sparkplug B, BACnet/IP, SQL poll, Omron, Modbus RTU; MELSEC deferred) plus the per-driver research reports. All docs went through a 7-agent parallel review against the codebase before this commit. Highlights fixed in review: - universal browser: FOCAS FixedTree fills post-connect -> UntilStable settle + FixedTree.Enabled patch; MQTT reconciled to bespoke (was contradicting the program doc's SupportsOnlineDiscovery=false verdict) - modbus-rtu: SerialPort.ReadTimeout doesn't bound async BaseStream reads -> linked-CTS per-op deadline (R2-01 class); BCL enum reuse would leak System.IO.Ports into Contracts - bacnet: DiscoveryRediscoverPolicy enum name; UDP 47808 contention; live suite rewritten around unicast Who-Is + BBMD (broadcast doesn't cross VMs) - sql-poll: real tier registration via DriverFactoryRegistry.Register; blackhole gate must not docker-pause the shared central SQL Server - mqtt: Sparkplug v3.0 STATE topic form; first-in-repo proto codegen noted - omron: host hardcodes isIdempotent:false today (retry seam unshipped); v1 scopes UDTs to dotted-leaf access - mtconnect: SecurityClassification.ViewOnly; factory ParseEnum<T> pattern - program doc: both valid enum-serialization patterns; IRediscoverable is change-signal-gated; RTU P2 adds System.IO.Ports; label is host-side
25 KiB
MTConnect (Agent-first) Driver — Research & Design
Status: Research / design proposal. No code written yet.
Author: research pass, 2026-07-15.
Scope: A new standard Equipment-kind driver (DriverType = "MTConnect") exposing an
MTConnect Agent's data under the OtOpcUa unified address space — the same shape as
Modbus / S7 / AbCip / TwinCAT / FOCAS / OpcUaClient.
0. TL;DR
- Browseable: YES. MTConnect
/probereturns a fully self-describing device model (Device → Components → DataItems) — a naturalIDriverBrowsertree, closest in spirit to the OpcUaClient browser. - Recommended library: TrakHound
MTConnect.NET(client packages), MIT-licensed. It is mature (v6.9.0.2, Oct 2025, 500k+ NuGet downloads), targets netstandard2.0 (so it loads on .NET 10) plus net6/7/8/9, and covers MTConnect versions up to 2.5. Hand-rolling is a viable fallback but not recommended for v1. - Read-only v1 (Discover + Read + Subscribe). No Write. MTConnect Interfaces (request/response write-back) exist but are rare, optional, and out of scope for v1.
- Top risks: (1) MTConnect data is loosely typed — DataItems are strings with
type/unitsmetadata, so the OPC UA data-type mapping is a heuristic, not a guarantee; (2)CONDITIONcategory has no clean scalar OPC UA analog and needs a modelling decision (fold to string vs. native Part 9 alarm).
1. Protocol summary + .NET library options
1.1 The two MTConnect layers
MTConnect (standard docs, SysML model v2.5) has two wire layers:
| Layer | Transport | Device model? | Role |
|---|---|---|---|
| Agent (primary) | HTTP REST, XML (or JSON) | Yes — self-describing | The queryable server. What apps talk to. |
| Adapter / SHDR | Raw pipe-delimited TCP (default :7878) |
No | Feeds an Agent from a machine; no model, no discovery. |
We target the Agent as the primary source. SHDR is a possible phase-2 ingest mode (§7) but loses auto-discovery.
1.2 Agent REST verbs
The Agent exposes three request types (MTConnect Standard Part 1, §5.4 / §8):
/probe(optionally/{device}/probe) → anMTConnectDevicesresponse document: the Device Information Model — everyDevice, its nestedComponents, and eachDataItemdefinition (id,type,category,subType,units,nativeUnits,name). This is the discovery + browse surface./current(optionally?at=<sequence>) → anMTConnectStreamsresponse document: a snapshot of the latestObservationfor every DataItem at the moment of the request. This is the Read surface./sample?from=<seq>&count=<n>&interval=<ms>→ anMTConnectStreamsdocument containing a time-ordered set of observations since sequencefrom. This is the Subscribe surface.
Streaming / long-poll. When interval is supplied, the Agent holds the HTTP connection open and
pushes successive MTConnectStreams chunks as a multipart/x-mixed-replace boundary stream —
each chunk is one batch of new observations. Each response Header carries nextSequence; a poller
issues the next /sample with from=nextSequence to get a contiguous, gap-free stream. The Agent's
ring buffer has a finite bufferSize; if a consumer falls behind past the buffer, it must re-/current
to re-baseline. (Sources:
MTConnect.NET README,
cppagent README,
Part 1 Overview.)
1.3 The information model (probe)
Device → Component(s) → DataItem(s). A DataItem has three categories (from the
SysML model):
- SAMPLE — continuous numeric measurement over time. Types e.g.
Position,Temperature,SpindleSpeed,PathFeedrate,Load,Acceleration. Carriesunits/nativeUnits. - EVENT — discrete state / value change. Types e.g.
Availability(AVAILABLE/UNAVAILABLE),Execution(READY/ACTIVE/INTERRUPTED/STOPPED),ControllerMode(AUTOMATIC/MANUAL/...),Program,EmergencyStop,Block. Values are typically controlled vocabularies (enums) or free strings/ints. - CONDITION — health/alarm state. An observation is reported as one of
Normal/Warning/Fault/Unavailable, with optionalnativeCode,nativeSeverity,qualifier, and message text. Subtypes:Actuator,Communications,System,Temperature,LogicProgram, etc.
An observation in an MTConnectStreams document references its definition by dataItemId (and
carries a sequence, timestamp, and value). This dataItemId is the stable per-tag address (§3).
1.4 .NET library options
Recommended: TrakHound MTConnect.NET client packages (MIT).
| Fact | Value | Source |
|---|---|---|
| Latest version | 6.9.0.2 (published 2025-10-16) | NuGet MTConnect.NET-HTTP |
| License | MIT (per latest NuGet package metadata) | NuGet MTConnect.NET-HTTP |
| TFMs | netstandard2.0, net6.0–net9.0, net48 (runs on .NET 10 via netstandard2.0) | README |
| MTConnect versions | up to 2.5; auto-strips data not valid for requested version | README |
| Maturity | 500k+ NuGet downloads; actively maintained | README |
Packages we would consume (client side only — not the Agent/Adapter host packages):
MTConnect.NET-Common— model types (IDevice,IComponent,IDataItem,IObservation), theIMTConnectClient/IMTConnectEntityClientinterfaces.MTConnect.NET-HTTP—MTConnectHttpClient: probe/current/sample with polling and streaming, gzip compression, XML and JSON. Exposes events for received documents (OnProbeReceived,OnCurrentReceived,OnSampleReceived, plus a per-observation callback) — a clean fit for pushing intoISubscribable.OnDataChange.- (
MTConnect.NET-XML/-JSONare pulled transitively for serialization.)
⚠️ License due-diligence caveat: older release notes and the repo copyright header have shown mixed "MIT" / "Apache-2.0" / "© TrakHound, All Rights Reserved" strings across versions. The current NuGet package metadata for 6.9.0.2 declares MIT. Before taking the dependency, pin a specific version and confirm that exact version's embedded
LICENSE/ package license expression (a legal-review checkbox, not a blocker).
Fallback: hand-roll a minimal XML client. The Agent protocol is just three HTTP GETs returning
XML. A hand-rolled client (HttpClient + System.Xml.Linq for probe, a multipart/x-mixed-replace
boundary reader for sample) is ~300–500 LoC and removes a third-party dependency + its transitive
System.Text.Json version constraints. Recommendation: use the library for v1 (it handles version
negotiation, the multipart framing, buffer-overflow re-baselining, and JSON/XML dual-format — all
fiddly to reimplement correctly), and keep hand-roll as a contingency if the license review fails.
2. Capability mapping to the Equipment-kind seams
The driver implements the composable capability interfaces in
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/. Mapping:
| OtOpcUa seam | MTConnect mechanism | Notes |
|---|---|---|
IDriver (required) |
Construct MTConnectHttpClient from config, hold /probe model |
InitializeAsync does one probe; GetHealth reflects last successful poll + Agent instanceId |
ITagDiscovery.DiscoverAsync |
Walk the /probe DeviceModel, stream Device→Component→DataItem into IAddressSpaceBuilder |
RediscoverPolicy = Once (probe is synchronous & complete). Redeploy on Agent instanceId change (see IRediscoverable, §below) |
IReadable.ReadAsync(fullRefs) |
/current snapshot, index observations by dataItemId, return one DataValueSnapshot per requested ref |
Reads are idempotent (matches IReadable contract). Per-ref miss → Bad-coded snapshot, not a throw |
ISubscribable.SubscribeAsync |
Start the MTConnectHttpClient sample stream (from/interval); fan each received observation to OnDataChange keyed by dataItemId |
One shared stream per driver instance (not per tag) — the Agent streams the whole device; filter to subscribed refs. Fire initial values from /current on subscribe (OPC UA convention) |
IWritable |
NOT implemented (v1) | See verdict below |
IRediscoverable.OnRediscoveryNeeded |
Raise when the Agent's Header/@instanceId or @assetBufferSize changes (Agent restarted / model changed) → Host rebuilds address space |
Mirrors Galaxy's DeployWatcher pattern |
IHostConnectivityProbe |
Cheap periodic /probe HEAD or /current to flip Running↔Stopped |
Mirrors ModbusProbeOptions |
IAlarmSource |
(optional, phase 1.5) map CONDITION Fault/Warning → Part 9 native alarm |
See §2.3 |
2.1 Write verdict: read-only for v1
Justification:
- The mainstream MTConnect surface (
/probe,/current,/sample) is strictly read-only by design — MTConnect was conceived as a read-only telemetry standard. - Write-back exists only via MTConnect Interfaces (a request/response handshake modelled as
its own DataItem categories —
Request/Responseevents) which is optional, rarely deployed, and semantically a state-machine handshake, not a simple "set value." Modelling it as OPC UAIWritablewould be misleading. - The Equipment-kind seams are composable — a driver implements only what its backend supports
(
IDriverdoc-comment). OmittingIWritableis idiomatic (Galaxy's write path is even fire-and-forget; several drivers are read-mostly). Nodes materialize without theAccessLevels.CurrentWritebit.
Revisit Interfaces write-back in a later phase only if a concrete deployment needs it.
2.2 Data-type mapping (MTConnect → DriverDataType → OPC UA)
MTConnect DataItems are weakly typed on the wire (values arrive as strings). The mapping is
inferred from category + type + units, and stored per-tag in the TagConfig (§3) so a wrong
inference can be corrected by the author without a code change.
| MTConnect | Inferred DriverDataType |
Rationale |
|---|---|---|
SAMPLE (numeric, has units) |
Float64 |
Continuous analog; Double is the safe superset |
SAMPLE w/ representation="TIME_SERIES" |
Float64 array (ValueRank=1) |
Time-series sample bursts |
EVENT controlled-vocab (Execution, ControllerMode, Availability, …) |
String |
Enum-like; keep the vocab string. (Optional: OPC UA enum modelling later) |
EVENT numeric (e.g. PartCount, Line) |
Int64 |
Integer counters |
EVENT free text (Program, Block, Message) |
String |
|
CONDITION |
String (v1: the state word Normal/Warning/Fault/Unavailable) or native alarm (§2.3) |
No clean scalar analog |
timestamp on every observation |
→ OPC UA SourceTimestamp |
Not a separate node |
DataValueSnapshot.StatusCode mapping: an observation value of UNAVAILABLE (MTConnect's explicit
"no data" sentinel) → OPC UA Bad/Uncertain quality rather than a literal string, so downstream
sees proper quality. Empty CONDITION or missing dataItem → Bad.
2.3 CONDITION modelling (decision point)
CONDITION is the awkward one. Two options:
- v1 simple: expose each CONDITION DataItem as a
Stringvariable node whose value is the current condition state (Fault/Warning/Normal/Unavailable), optionally suffixed withnativeCode. Zero alarm plumbing. - v1.5 native: treat a Fault/Warning CONDITION as an OtOpcUa native alarm by emitting a
TagConfig
alarmobject and implementingIAlarmSource— matching the Galaxy/Phase-B native-alarm pattern (route on the authored dotted reference, per the alarms memory). Higher value, more work.
Recommend v1 simple, v1.5 native as a fast-follow.
3. TagConfig JSON shape
Consistent with the Equipment-kind model: connection-level settings live in the driver config
(agent base URL, auth, poll interval); per-tag addressing lives in TagConfig, and the platform
FullName binds the tag to a driver-side reference. For MTConnect the natural stable reference is the
dataItemId (globally unique within an Agent), so FullName = "<dataItemId>" (or, for
readability, the driver can also accept a device/component/dataItemName path and resolve it to the id
at discovery time).
3.1 Driver config (per driver instance) — MTConnectDriverOptions
{
"AgentUri": "https://demo.mtconnect.org", // Agent base URL (http or https)
"DeviceName": "", // optional: scope to one device (else all devices)
"PreferJson": false, // request application/json instead of XML if Agent supports it
"SampleIntervalMs": 1000, // long-poll interval passed to /sample
"SampleCount": 500, // max observations per sample chunk
"HeartbeatMs": 10000, // Agent keep-alive heartbeat
"RequestTimeoutMs": 30000,
"AllowUntrustedTls": false, // dev/on-prem self-signed agents
"Probe": { "Enabled": true, "IntervalMs": 5000 } // IHostConnectivityProbe
}
3.2 Per-tag TagConfig (authored on the /uns Tags tab)
{
"FullName": "avail", // <-- MTConnect dataItemId; the driver-side reference
"mtDevice": "OKUMA.Lathe", // optional human context (device name from probe)
"mtComponent": "Controller", // optional
"mtCategory": "EVENT", // SAMPLE | EVENT | CONDITION (from probe; drives typing)
"mtType": "AVAILABILITY", // MTConnect DataItem type
"mtSubType": null,
"dataType": "String", // resolved DriverDataType (overridable by author)
"units": null, // e.g. "CELSIUS" for a SAMPLE
"writable": false // always false for MTConnect v1
}
A picked SAMPLE example:
{
"FullName": "Xact",
"mtDevice": "OKUMA.Lathe",
"mtComponent": "Linear[X]",
"mtCategory": "SAMPLE",
"mtType": "POSITION",
"mtSubType": "ACTUAL",
"dataType": "Float64",
"units": "MILLIMETER",
"writable": false
}
The runtime driver resolves reads/subscriptions by FullName (= dataItemId) against the observation
index it maintains from /current and /sample — exactly the "bind by TagConfig.FullName" pattern
the other drivers use (EquipmentTagRefResolver<TDef>). Only FullName is load-bearing at runtime;
the mt*/units fields are author-facing metadata + type inference inputs preserved across edits.
4. Browseability verdict + browse design
4.1 Verdict: YES — fully browseable.
/probe returns the complete MTConnectDevices model in one call — a static, self-describing tree.
This is the cleanest possible browse source (better than Galaxy, which needs per-node expansion;
comparable to a single-shot OPC UA browse). It maps directly onto IDriverBrowser/IBrowseSession.
4.2 New project: ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Browser
Mirror OpcUaClient.Browser layout exactly:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Browser/
MTConnectDriverBrowser.cs // IDriverBrowser: DriverType="MTConnect"; OpenAsync(configJson) -> session
MTConnectBrowseSession.cs // IBrowseSession over the fetched probe model
ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Browser.csproj
Register in AdminUI DI alongside the others
(src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs, ~line 49-50):
services.AddSingleton<IDriverBrowser, MTConnectDriverBrowser>();
4.3 Session behaviour (map to IBrowseSession)
Unlike OPC UA (live one-level-per-call), MTConnect probe is fetched once in
OpenAsync and cached in the session; expansion is served from the in-memory tree (no further network
calls). This is simpler and faster than the OpcUaClient browser.
| Method | Returns |
|---|---|
OpenAsync(configJson) |
Deserialize MTConnectDriverOptions, GET {AgentUri}/probe, hold the parsed IDevice[] model in the session |
RootAsync() |
One BrowseNode(Kind=Folder) per Device (or, if DeviceName is scoped, the top-level Components of that device) |
ExpandAsync(nodeId) |
Children of a Device/Component: nested Components → Folder nodes; DataItems → Leaf nodes. NodeId encodes the path (e.g. dev:OKUMA.Lathe, comp:<componentId>, di:<dataItemId>) |
AttributesAsync(nodeId) |
For a DataItem leaf, one-or-few AttributeInfo rows describing it (Name=dataItemName, DriverDataType=<inferred>, IsArray=<TIME_SERIES?>, SecurityClass, IsAlarm = (category==CONDITION)). For Device/Component folders, empty. This drives the picker's side-panel and lets a CONDITION pre-fill a native-alarm TagConfig (like Galaxy alarm attrs) |
BrowseNode.NodeIdfor a DataItem leaf isdi:<dataItemId>→ the picker commits it, and the TagModal maps it toTagConfig.FullName = <dataItemId>plus themt*metadata read from the cached probe model.BrowseNodeKind.Leaf= DataItem (terminal, commit-on-select);Folder= Device/Component.HasChildrenHint = truefor Devices/Components that have child components or DataItems.
4.4 Picked-DataItem → TagConfig
When the picker commits a di:<dataItemId> leaf, the AdminUI builds the TagConfig in §3.2 from the
cached probe DataItem: FullName=id, mtCategory/mtType/mtSubType/units copied verbatim, and
dataType set from the §2.2 inference table (author can override in the typed editor).
5. Typed tag-editor (AdminUI)
To avoid the raw-JSON fallback, add a driver-typed editor (mirrors the driver-typed tag-editor pattern):
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs(pureFromJson/ToJson/Validate, preserves unknown keys) — copy the Modbus template..../Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor— a thin shell: fields forFullName(dataItemId), read-onlymtCategory/mtType, and adataTypeoverride dropdown.- Register
["MTConnect"] = typeof(...MTConnectTagConfigEditor)inUns/TagEditors/TagConfigEditorMap.cs, and add validation inTagConfigValidator.
Because most tags are authored via the browse picker (which fills the model), the editor is mostly a confirm/override surface.
6. Test-fixture strategy
Ranked by value:
-
Public demo Agents (fastest smoke path).
https://demo.mtconnect.org/— the MTConnect Institute reference demo (probe/current/sample).http://mtconnect.mazakcorp.com/— Mazak demo agents.- NIST smstestbed publishes real agent configs +
Devices.xml(usnistgov/smstestbed). These give a live, real-shape model for browse + read + streaming verification with zero infra. (Caveat: public agents are internet-dependent and not reproducible in CI.)
-
Dockerized reference C++ Agent (reproducible integration fixture). The MTConnect reference Agent (mtconnect/cppagent) has an official Docker image. Add a fixture under
tests/.../Docker/docker-compose.ymlwith theproject: lmxopcualabel (per the repo's Docker workflow), seeded with a cannedDevices.xmland an SHDR simulator or the built-in agent adapter, exposed on the shared docker host10.100.0.35. This is the integration-test analog of the Modbus/S7 sims. -
Canned probe/current/sample XML fixtures (unit tests). Capture one
MTConnectDevices(probe) and a couple ofMTConnectStreams(current + sample) XML documents from a demo agent intotests/.../MTConnect.Tests/Fixtures/. Drive the parser, type-inference, browse-tree, and observation-indexing logic with no network — the bulk of unit coverage. This is where the tricky bits (type inference,nextSequencepaging,UNAVAILABLE→Bad, CONDITION handling, multipart chunk framing) get pinned.
Recommended: #3 for unit CI + #2 for the env-gated integration suite + #1 for manual live smoke.
7. Effort / risk / phasing
Phase 1 — Agent read + subscribe + browse (the MVP)
- Projects:
Driver.MTConnect(+.ContractsforMTConnectDriverOptions/DTOs),Driver.MTConnect.Browser,MTConnect.Tests. - Implement
IDriver+ITagDiscovery(probe→builder) +IReadable(current) +ISubscribable(sample stream) +IHostConnectivityProbe+IRediscoverable(instanceId change). IDriverProbefor the AdminUI Test-Connect button (probe reachability).IDriverBrowser/IBrowseSessionfrom cached probe model; register in AdminUI DI.- Typed tag editor +
TagConfigEditorMap/TagConfigValidatorentries. - Register the factory in the Host bootstrapper (mirror
ModbusDriverFactoryExtensions.Register). - CONDITION as
String(simple). - Effort estimate: ~1–1.5 weeks with the TrakHound library (it removes the protocol grind). ~2.5–3 weeks hand-rolled.
Phase 1.5 (fast-follow)
- CONDITION → native OPC UA Part 9 alarms via
IAlarmSource(Galaxy native-alarm pattern). TIME_SERIESSAMPLE arrays; EVENT controlled-vocab → OPC UA enumerations.
Phase 2 — SHDR adapter ingest (second source mode)
- Add an optional
SourceMode: "Agent" | "Shdr"to the driver config. In SHDR mode the driver opens the raw pipe-delimited TCP socket (default:7878) and parses<timestamp>|<key>|<value>lines. - Loses auto-discovery: SHDR has no device model, so
ITagDiscoverycannot self-populate — tags must be manually authored (like Modbus), and the browser is unavailable in SHDR mode. Type inference falls back to author-supplieddataType. - Value: connect directly to a machine adapter with no Agent deployed. Niche; do only on demand.
Risk register
| Risk | Sev | Mitigation |
|---|---|---|
| Weak wire typing → wrong OPC UA type inference | Med | Store resolved dataType in TagConfig; author-overridable; unit-test the inference table against real probe XML |
| CONDITION has no scalar analog | Med | v1 = String state; v1.5 = native alarm |
| TrakHound license string inconsistent across versions | Low-Med | Pin a version; confirm its embedded MIT license in legal review before merge |
| Public demo agents unreliable for CI | Low | Docker cppagent fixture + canned XML for CI; demo agents only for manual smoke |
| Agent ring-buffer overflow if consumer stalls | Low | Library re-baselines via /current on sequence gap; verify + test the fall-behind path |
netstandard2.0-only lib on .NET 10 |
Low | netstandard2.0 loads on net10.0; validate no trim/AOT issues at build |
8. Source references
- MTConnect Standard Part 1 (Overview & Protocol): https://docs.mtconnect.org/MTC_Part_1_Overview+V1.2.pdf
- MTConnect SysML Model v2.5 (Fundamentals — Device/Component/DataItem, categories): https://model.mtconnect.org/Version2.5/Fundamentals/
- TrakHound MTConnect.NET (README, client API, TFMs, versions): https://github.com/TrakHound/MTConnect.NET/blob/master/README.md
- NuGet MTConnect.NET-HTTP (license=MIT, v6.9.0.2 2025-10-16, TFMs, deps): https://www.nuget.org/packages/MTConnect.NET-HTTP/
- MTConnect reference C++ Agent (Docker fixture): https://github.com/mtconnect/cppagent/blob/master/README.md
- NIST smstestbed agent config + Devices.xml (real fixtures): https://github.com/usnistgov/smstestbed/blob/master/mtconnect/agent/Devices.xml
- Public demo agent: https://demo.mtconnect.org/ · Mazak demo agents: http://mtconnect.mazakcorp.com/
OtOpcUa code seams referenced
- Capability interfaces:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/(IDriver,ITagDiscovery,IReadable,ISubscribable,IWritable,IRediscoverable,IHostConnectivityProbe,IDriverFactory,DriverDataType). - Browse seam:
src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/(IDriverBrowser,IBrowseSession,BrowseNode,AttributeInfo). - Browser template:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/. - Driver template:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/(+.Contracts), factoryModbusDriverFactoryExtensions. - AdminUI browser DI registration:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs:49-50. - Typed tag editors:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs+TagConfigValidator.cs. - Browse design doc:
docs/plans/2026-05-28-driver-browsers-design.md.