Skip to content

Organizers._systems list is stale — causes asymmetric <Systems> auto-nesting in Device.AddComponent() #134

@ottobolyos

Description

@ottobolyos

Summary

MTConnect.NET-Common's Organizers static class hand-maintains a list of System substitution-group members used by Device.AddComponent() to decide whether to auto-wrap a component under a <Systems> organiser. The list has drifted: it includes components that are not (any longer) System substitution-group members per the current XSD, and omits several System subtypes that ARE substitution-group members per the v2.5 + v2.6 + v2.7 XSDs — most notably Heating. As a result, device.AddComponent(new ProtectiveComponent()) is auto-wrapped under <Systems> (because Protective is in the list) while device.AddComponent(new HeatingComponent()) is left as a direct child of Device (because Heating is NOT in the list). Two components that are equivalent under the XSD substitution-group model are placed at different nesting levels.

Environment

  • MTConnect.NET-Common at v6.9.0 (official Docker image trakhound/mtconnect.net-agent:6.9.0, published 2025-10-16 — binary-equivalent to v6.9.0.2). The bug is library-level, not tied to any particular host application.
  • Reproduction wrapper used for the live test: a publicly-available third-party MT.NET wrapper image — any MT.NET client that calls Device.AddComponent() programmatically (rather than loading the Device tree from XML) surfaces the bug identically.
  • Broker (for the live repro): eclipse-mosquitto:2.0.22.
  • Relevant library files: libraries/MTConnect.NET-Common/Devices/Organizers.cs + libraries/MTConnect.NET-Common/Devices/Device.cs + libraries/MTConnect.NET-Common/Devices/Components/*Component.g.cs.

Verified — 2026-04-20 — source inspection + live reproduction

Live reproduction

Tested via a third-party MT.NET wrapper image (publicly available on Docker Hub) that embeds this library and calls Device.AddComponent() for each XPath path part when building a Device tree from YAML-declared clauses like Device[...]/Heating[...]/...). With a minimal configuration declaring both Heating[name=MainController] and Protective[name=HiLimitController] as peer children of the same Device, the resulting Probe JSON (captured from the MQTT MTConnect/Probe/<uuid> topic) is:

{
  "Device": {
    "Components": {
      "Heating": [
        { "id": "", "name": "MainController", "DataItems": { } }
      ],
      "Systems": [                        /* ← auto-created organiser */
        {
          "id": "…_systems",
          "name": "systems",
          "Components": {
            "Protective": [
              { "id": "", "name": "HiLimitController", "DataItems": { } }
            ]
          }
        }
      ]
    }
  }
}

Both components were declared at the same level in the wrapper's configuration. The library auto-wrapped Protective but left Heating at Device level — matching the per-subtype branching the source code analysis predicts. Evidence file: probe-third-party-wrapper.json.

Source inspection

The asymmetric list

libraries/MTConnect.NET-Common/Devices/Organizers.cs lines 85-99:

private static readonly IEnumerable<string> _systems = new List<string>
{
    ControllerComponent.TypeId,
    CoolantComponent.TypeId,
    DielectricComponent.TypeId,
    ElectricComponent.TypeId,
    EnclosureComponent.TypeId,
    EndEffectorComponent.TypeId,
    FeederComponent.TypeId,
    HydraulicComponent.TypeId,            // ← not a System subtype per v2.5/v2.7 XSD
    LubricationComponent.TypeId,
    PneumaticComponent.TypeId,            // ← not a System subtype per v2.5/v2.7 XSD
    ProcessPowerComponent.TypeId,
    ProtectiveComponent.TypeId,
    WorkEnvelopeComponent.TypeId
};

The actual System subtypes (v2.5 XSD — the library's own advertised ceiling)

Per grep "substitutionGroup='System'" MTConnectDevices_2.5.xsd:

Subtype In _systems? MT.NET component class exists?
Actuator Missing ActuatorComponent.g.cs
Controller Present
Coolant Present
Dielectric Present
Electric Present
Enclosure Present
EndEffector Present
Feeder Present
Heating Missing HeatingComponent.g.cs
Lubrication Present
ProcessPower Present
Protective Present
Vacuum Missing VacuumComponent.g.cs
Cooling Missing CoolingComponent.g.cs
Pressure Missing PressureComponent.g.cs
WorkEnvelope Present
AirHandler Missing AirHandlerComponent.g.cs

v2.7 adds ToolHolder and PinTool (also missing from _systems).

Phantoms in the list (not substitutionGroup='System' in v2.5 or v2.7 XSD):

  • Hydraulic — no element <xs:element name='Hydraulic' ... substitutionGroup='System'> in current XSDs. Likely an artefact from an older version.
  • Pneumatic — same.

The buggy code path

libraries/MTConnect.NET-Common/Devices/Device.cs lines 317-360:

public void AddComponent(IComponent component)
{
    ...
    var organizerType = Organizers.GetOrganizerType(component.Type);
    if (organizerType != null && organizerType != ControllersComponent.TypeId)
    {
        if (!components.Any(o => o.Type == organizerType))
        {
            var organizer = Component.Create(organizerType);
            if (organizer != null)
            {
                organizer.Parent = this;
                organizer.Id = Component.CreateContainerId(organizer, ComponentIdFormat);
                organizer.AddComponent(component);          // ← Protective gets wrapped here
                components.Add(organizer);
            }
        }
        else { /* add to existing organizer */ }
    }
    else
    {
        components.Add(component);                          // ← Heating falls here (type not in list)
    }
    ...
}

GetOrganizerType(component.Type) returns SystemsComponent.TypeId only if component.Type is in the _systems list. For Protective it returns SystemsComponent.TypeId; for Heating it returns null. The two components therefore land at different tree levels.

Consequence for operators

The MT.NET library ships every System subtype as a C# class (auto-generated .g.cs files under libraries/MTConnect.NET-Common/Devices/Components/). A consumer / wrapper that builds a Device programmatically via AddComponent() — for example:

device.AddComponent(new HeatingComponent   { Name = "MainController" });
device.AddComponent(new ProtectiveComponent{ Name = "HiLimitController" });

…ends up with the two components at different depths in the Probe tree: Heating at Device/Components/Heating, Protective at Device/Components/Systems/Components/Protective. No XSD rule justifies the split; both are identical substitution-group members of System under CommonComponent.

Why the XML-load path escapes this

When a Device is loaded from an XML file, the parser preserves the element tree as-written (tested live 2026-04-20 with both forms of the test device XML — see tech-writeup-substitution-group-placement.md). The bug surfaces only on the programmatic AddComponent() path.

Authority

MTConnectDevices_{2.5,2.6,2.7}.xsdSystem substitution-group members enumerated via <xs:element name='X' type='XType' substitutionGroup='System'>. Every member is interchangeable under the XSD's substitution-group semantics; asymmetric treatment by a producer breaks that interchangeability guarantee.

Cross-implementation comparison (both XSD-valid forms, both load paths)

Per the XSD, System substitution-group members may appear in two equally-valid forms:

  • Form A — direct placement under Device/Components: e.g. <Device><Components><Heating/><Protective/></Components></Device>.
  • Form B — nested under a <Systems> organiser: e.g. <Device><Components><Systems><Components><Heating/><Protective/></Components></Systems></Components></Device>.

The XSD does not prefer one form over the other and it requires uniform treatment of every substitution-group member.

XML-load path — both agents preserve the author's form verbatim

Tested live 2026-04-20 with identical Devices.xml files against MTConnect.NET v6.9.0 and cppagent v2.7.0.7. Both agents preserve Form A input as Form A output, and Form B input as Form B output. Neither auto-wraps during XML load. Good — the bug is not in the XML parser.

Programmatic path — MTConnect.NET auto-wraps asymmetrically

Device.AddComponent() consults the stale Organizers._systems list and wraps Protective-type components under a <Systems> organiser while leaving Heating-type components as direct children of Device (see Root cause / The asymmetric list sections above for the exhaustive subtype table). cppagent does not expose an equivalent programmatic API path, so the defect is MT.NET-specific.

Three-way verification summary

Agent Path Asymmetric nesting? Why
MT.NET direct (XML-load) Devices.xml → Probe No XML-load preserves author layout
cppagent direct (XML-load) Devices.xml → Probe No Same
MT.NET programmatic wrapper AddComponent() per-path → Probe Yes Organizers._systems list is incomplete — Heating missing, Protective present

Fix lives entirely in Organizers.cs (list regeneration from XSD) + Device.AddComponent() (decide whether auto-wrap should happen at all — see Suggested fix below).

Suggested fix

  1. Regenerate Organizers._systems from the XSD or from the same source used for the generated *Component.g.cs class files. The hand-maintained list has drifted; if the class files are auto-generated, the organiser mapping should be too.
  2. Remove phantoms (Hydraulic, Pneumatic) unless they are re-introduced in a future XSD.
  3. Add a unit test that iterates every *Component class whose XSD element has substitutionGroup='System' and asserts Organizers.GetOrganizerType(typeId) == SystemsComponent.TypeId.
  4. Consider whether Device.AddComponent() should auto-wrap at all. The XSD permits both forms; respecting the caller's placement (never auto-wrap) is the minimum-surprise alternative. If auto-wrap stays, it must apply uniformly to every substitution-group member.
  5. Apply the same audit to every _auxiliaries, _axes, _interfaces, _materials, _parts, _processes, _resources, _structures list in Organizers.cs.

Impact

  • Any MTConnect.NET-based wrapper that constructs Device trees programmatically — e.g. from XPath clauses, from a custom DSL, from configuration — inherits the asymmetry silently. See one such wrapper's XPath-to-Device builder seen in the wild for an example that had to add a "deep search" compensation step with an explicit comment "agent may auto-nest components (e.g., under Systems)".
  • Consumers that walk the Probe tree by substitution-group type end up with per-subtype lookup paths instead of a uniform walk.
  • The XSD substitution-group contract (members are interchangeable) is violated at the producer side.

Stability across MTConnect versions

  • Heating has been a substitutionGroup='System' element since v1.5 (verified against MTConnectDevices_1.5.xsd onwards); Actuator, Vacuum, Cooling, Pressure, AirHandler similarly stable across every v2.x XSD. The asymmetric-nesting defect has therefore existed against every MTConnect version the library advertises support for (v2.0 floor through v2.5 ceiling), not just against v2.6 / v2.7.
  • v2.7 introduces ToolHolder and PinTool as additional System subtypes — also absent from Organizers._systems. The uplift (companion issue Add support for MTConnect Standard v2.6 and v2.7 #133) inherits this defect unless the list is regenerated.
  • The phantoms (Hydraulic, Pneumatic) appear to be legacy entries from an older MTConnect version where these elements were directly substitution-group members of System; neither appears in v2.5 or later XSDs.
  • Fix is single-location and applies cleanly across the entire v2.x range.

Related issues

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions