Status: Accepted (revised 2026-05-09 — see §14) Date: 2026-04-25 Decision Makers: Development Team Consulted: Energy Domain SMEs, Customer Operators Informed: EMS Subsystem Maintainers, Course Participants
- 2026-04-25 — Initial acceptance.
- 2026-05-09 — §7 scope clarified; §14 added. Dynamic device-level CRUD via
ems-device-apiis now in scope. Template authoring remains PR-gated and out of runtime scope. Earlier "topology changes only via redeployment" stance is superseded. - 2026-05-09 — §7 template versioning (
vN) dropped for MVP. Templates are PR-gated; git history is the version source.vNreserved for future fleet-management work. §14 implications updated: HMI is read-only and not a CRUD client.
ADR-001 commits to MQTT as the central event bus. That decision did not specify topic structure, payload format, unit handling, or naming conventions. Without a concrete contract, every subsystem (gateway, line-controller, HMI, analyst) would invent its own — and existing draft readmes already showed three incompatible topic shapes (sites/.../measurements/{unit}/{value} in gateway, sites/.../calculations/... in PST, arcnode/{deployment_uuid}/... in device-api).
The contract must satisfy:
- Customer-owned naming — operators may run multiple sites and don't want a vendor prefix in their topics
- Stable topics under firmware/spec evolution — adding a measurement should not invalidate existing subscribers
- Operator debuggability —
mosquitto_sub -t '#'at 3am should be readable - Industrial gear realities — Modbus/DNP3/SNMP carry raw integers with vendor-specific scaling; raw values must not leak onto the wire
- MVP-scoped vocabulary — defer optional fields until scoped code needs them
- Codegen drives consumers — gateway, line-controller, HMI all generate types from a single AsyncAPI v3 spec served by
ems-device-api
A two-family topic model with fixed-depth segments per family, strict {ts, value} payloads, and unit-encoded topic slots backed by an enum-locked vocabulary in the AsyncAPI spec. Device templates own the canonical vocabulary; the per-deployment DTM overlays customer-facing display names.
Decision: Every application-level message belongs to exactly one of two families. measurements/ carries everything a device emits (sensor readings, computed values, device health, alarm state, command acks). commands/ carries everything sent to a device.
Rationale:
- Mirrors Modbus read/write and DNP3 master/outstation flow
- Removes the gray zone between "measurement," "state," "status," and "calculation" — they are all things the device emits, so they are all measurements
- Two parsers, two type-safe handlers downstream
Implications:
- Boolean and enum readings (rain, tap_position, alarm_state) are measurements with
unit=none, not a separate family - Derived quantities (DLR dynamic rating) are measurements — the consumer doesn't care that the publisher computed them
- A third top-level prefix
system/carries broker-internal events (topology changed, etc.); not a third family
Decision: Measurements are 6 segments. Commands are 7 segments. Different depths, different parsers, no shared overload.
sites/{site_id}/devices/{device_id}/measurements/{measurement}/{unit} # 6
sites/{site_id}/devices/{device_id}/commands/{verb}/{target}/{unit} # 7
system/{event_type} # 2
Rationale:
- Variable-depth wildcards become parsing nightmares; fixed depth makes broker-level subscriptions predictable
- Splitting commands as
verb/target/unitgives broker-level ACL hooks (e.g. allowset/+/+, denyreset/+/+) without payload introspection - The parser switches on slot 4 (
family ∈ {measurements, commands}) and dispatches
Implications:
- Unitless measurements use a literal
nonesentinel in the unit slot (e.g.measurements/rain/none) nonechosen overnullto avoid JSON-coercion accidents in deserializers- Verb is enum-locked:
[set, reset, clear, start, stop, enable, disable]
Decision: Slot 6 of measurements (and of commands when applicable) carries the engineering unit. The AsyncAPI parameter schema locks the enum.
Rationale:
- Operator can
mosquitto_suband read units inline - Broker-level filters work directly:
+/+/measurements/+/celsius - Enum-locked vocabulary prevents
degCvscelsiusdivergence — typo at codegen time, build fails
MVP unit vocabulary (extend by AsyncAPI spec change → patch-level version bump):
volts | amps | watts | vars | voltamperes | watt_hours | hertz |
celsius | percent | watts_per_m2 | meters_per_second |
bar | liters_per_minute | none
Naming rules: lowercase, plural where natural (volts not volt), short compounds (watts_per_m2 not watts_per_meter_squared), vars for reactive power (var collides with reserved words).
Implications:
- Adding a unit is a spec change →
system/topology_changed→ consumers re-fetch — not free, but additive - SI-prefixed duplicates rejected:
watt_hoursonly, nokilowatt_hours. JSON float64 has range to spare.
Decision: Industrial protocols (Modbus, DNP3, SNMP, CAN) carry raw integers with vendor-specific scaling. The gateway applies scale and offset; MQTT carries the engineering value. Scaling metadata lives in the AsyncAPI spec under x-source per channel for audit.
x-source:
protocol: modbus_tcp
server_unit_id: 1
register_type: holding
address: 3000
data_type: int16
word_order: high_low
scale: 0.1
offset: 0Rationale:
- Industry standard: gateway is the unit-translation boundary
- Downstream consumers (HMI, analyst, line-controller) stay simple — no scaling logic replicated three times
- Spec records the math, answering "where did this number come from" for regulatory audit
Implications:
- Drift between gateway code and spec is the only failure mode — mitigated because gateway code is itself codegenned from the spec
Decision: Every sample payload is {ts, value}. No quality, no source flags, no correlation IDs. Quality information lives in a separate status measurement, joined by timestamp at query time.
Rationale:
- Wire stays minimal; payload doesn't repeat metadata that doesn't change per sample
- Quality is recoverable post-hoc via
LATERALjoin of status ≤ sample.ts — same pattern PI/Ignition use internally - MQTT LWT handles publisher-level death; per-device
statusmeasurement handles sub-device degradation
Implications:
- Industrial-historian egress (PI, Ignition) needs an adapter that does the time-join. Acceptable trade for wire minimalism.
tsis RFC3339/ISO8601 string withZsuffix — TimescaleDB / TigerData ingest natively intoTIMESTAMPTZwith no conversion code
Decision: Four sample schemas in the spec.
FloatSample { ts, value: number }
BooleanSample { ts, value: boolean }
EnumSample { ts, value: string } # value constrained to enum:[...] in the channel schema
TriggerSample { ts } # commands only — reset, clear, fire-and-forget
The EnumSample channel schema in the generated AsyncAPI spec sets value.enum to the ordered list of label keys from the template values: block (e.g. ["AUTO", "MANUAL", "RUNPQ"]). The gateway translates raw register integers to the matching label before publish; the HMI renders the label it receives — no int-to-string logic in any consumer.
Rationale: Symmetric across measurements and commands where shape matches; trigger is command-only (a reset has no payload value).
Verb → schema mapping:
| Verb | Schema |
|---|---|
| set | Float / Boolean / Enum (depending on target) |
| start, stop, enable, disable | Boolean (persistent state) |
| reset, clear | Trigger |
Decision: Measurements, commands, units, and protocol bindings for each device type live in a device template (e.g. bess_module, dlr_sensor). The DTM references templates by bare slug; it does not redefine measurements per deployment. Template authoring is PR-gated; git history is the version source. A vN suffix is reserved for future fleet-management work where multiple firmware revisions of the same model are in flight simultaneously — out of scope for MVP.
We use template rather than class deliberately — "class" is OOP jargon that doesn't translate to industrial operators or integrators, while template is the standard SCADA/HMI term for "a definition you instantiate." OPC UA's ObjectType, IEC 61850's Logical Node Type, BACnet's ObjectType, and Sparkplug's Device Definition all model the same concept; we picked the term that reads cleanly in operator-facing UI, ADR text, and code without overloading either type or profile (the latter collides with IEC 61850 conformance profiles).
Positioning — ARCNODE owns the inside of every container we ship and the integration of every BESS we support. The three module types are compute_module and grid_module (arcnode-fabricated containers — GPU servers, NVLink switches, DLC pumps + plate heat exchangers; AC switchgear, transformer, PCS, metering relays) and bess_module (BYO — Tesla Megapack, Tesla Megablock, CATL EnerOne). Templates are not vendor-agnostic placeholders. For arcnode-fab containers, templates encode our hardware design end-to-end. For BESS modules, templates encode the supported vendor's protocol surface and register map, authored by the ARCNODE team. Templates are first-class, opinionated definitions, PR-gated like source code. Software architecture leans on this — codegen targets are specific (we know exactly what a bess_module exposes), HMI views are designed against known shapes (not generic "render whatever the device says"), and template authoring discipline is rigorous (every PR is a real hardware decision). User-defined template authoring is explicitly out of scope for MVP — new templates land via PR to edp-api/device_templates/, not at runtime. Per-template extensibility within an existing template lives at the per-device extra_measurements: escape hatch in the DTM. Device-level dynamic CRUD — instantiating an existing template at a new device-id, removing a device, updating a device's display name or connection — is in scope and lives in ems-device-api; see §14.
Rationale:
- Bikeshedding ("
voltage_dchere,dc_voltagethere") resolved once at template-design time - Aligns with the modular hardware product surface — three module-level templates as roots (
compute_module,grid_module,bess_module), equipment templates beneath, all curated to real datasheets - Git history is the version source for MVP; template-file PRs carry the audit trail. Explicit
vNadds discipline cost without buying anything until multiple firmware revisions of the same model are in flight simultaneously.
Implications:
- Templates are the single PR-gated vocabulary surface. Canonical home is
edp-api/device_templates/—edp-apiis both author and sole runtime reader, no separate distribution repo. Promote to a dedicated repo when template count exceeds ~12 or external integrators need consumption-only access. edp-apiwalks itsdevice_templates/at startup, validates every YAML, and uses the templates for sizing.- DTMs are self-describing:
edp-apiembeds atemplates_used: Record<templateRef, DeviceTemplate>map in every DTM payload it emits, containing exactly the templates referenced by thedevicesblock. Downstream consumers (ems-device-api,ems-hmi, analyst, gateway) receive everything they need in one POST — no separate template-catalog endpoint, no separate fetch. - Per-deployment customizations of canonical measurements use a per-device
extra_measurements:escape hatch in the DTM. Use sparingly — adding to a template is preferred over a one-off override.
Template hierarchy mirrors real hardware: the three module types from the product surface (compute_module, grid_module — arcnode-fab 10ft ISO containers; bess_module — BYO third-party BESS) are module-level templates — they sit at the root of the device tree. Equipment within each module is represented as descendant devices at any depth. Real industrial hierarchies are typically 3+ levels deep — a battery module contains racks which contain BMS / inverter / cells; a compute module contains chassis which contain servers which contain GPUs; a grid module contains switchgear which contains breakers and metering CTs. Each template declares a contains: block listing its direct child templates (required and scalable). Containment chains arbitrarily — depth is unbounded. The DTM expresses this as a parent-chain tree: every device has an optional parent: device_id pointer. Topic addressing stays flat — device_id in the topic is the leaf identifier, not a path; depth lives in the parent chain, walked by HMI/analyst codegen at render time. IEC 61850 alignment is carried as optional metadata (iec_61850.logical_nodes, iec_61850_ref per measurement) for the MVP — strict structural superset is a v1.x goal once an SCD import path lands.
Example tree (BESS-shaped, identical pattern for compute and grid):
bess_module_1 template: bess_module, parent: null
├── bess_rack_1 template: bess_rack, parent: bess_module_1
│ ├── bess_bms_1 template: bess_bms, parent: bess_rack_1
│ ├── bess_inverter_1 template: bess_inverter, parent: bess_rack_1
│ └── bess_cell_1..N template: bess_cell, parent: bess_rack_1
├── bess_rack_2
└── ...
Each level can declare its own measurements/commands. Module-level rollups (whole-module SOC), mid-level rollups (per-rack spread), and leaf reads (per-cell voltage) are independent channels, all flat in the topic, related only by parent links in the DTM.
Decision: Topic, code, and spec use the canonical name (e.g. voltage_dc). The DTM carries a display_name override per site, device, and measurement, propagated to the AsyncAPI spec as x-display-name and rendered by the HMI.
Rationale:
- Customer can call it "Pile A DC Bus" without breaking topic stability
- Renaming a label is a metadata-only change → patch-level spec bump → re-fetch but no resubscribe
- Codegen still produces deterministic Rust struct fields and TypeScript constants from canonical names
Resolution order: DTM override → template default → humanized canonical.
Decision: All identifiers (site_id, device_id, measurement, command target) are snake_case slugs matching ^[a-z][a-z0-9_]{0,62}[a-z0-9]$. They are immutable once a device is provisioned. Display names are mutable, identifiers are not.
Rationale: UUID identifiers destroy operator readability in topics; renaming would invalidate every subscriber.
Uniqueness scope: site_id unique within deployment, device_id unique within site, measurement unique within template.
Decision: AsyncAPI spec uses semver. Patch = metadata fix; minor = additive (new channel, new device); major = breaking (rename, remove, unit change). system/topology_changed carries only {ts, version} — consumers re-fetch the full spec and compute the diff client-side.
Rationale:
- Spec is source of truth — diff computation in the event payload could lie or get stale
- Consumers always re-fetch unconditionally; version is primarily an audit trail
- Operator change-management benefits from operator-readable version strings (
2.4.7 → 3.0.0reads as "expect breakage")
Decision:
| Family | QoS | Retain | Why |
|---|---|---|---|
| measurements | 0 | true | High-rate telemetry; new subscriber sees latest immediately |
| commands | 1 | false | Imperative-at-time; replay on broker restart is a safety hazard |
| system | 1 | false | Transient triggers; old events should not fire on new connect |
| status (LWT) | 1 | true | Liveness state must persist for the device offline-flag pattern |
Rationale: Industrial-conventional. Retain on commands is the dangerous default — explicitly off so a setpoint replay does not unexpectedly spin a BESS.
Decision: The AsyncAPI v3 spec served by ems-device-api is written from the perspective of a deployed device (or device-like publisher: gateway, line-controller). action: send means the device publishes; action: receive means the device subscribes. Topology-update events on system/topology_changed appear as action: receive since devices subscribe to them.
Consumers that are not devices — ems-hmi, ems-analyst-server, platform-api — invert this during their own codegen pass: what the device sends, the consumer subscribes to; what the device receives, the consumer publishes.
Rationale: device codegen is the most performance-sensitive path (Rust on ESP32 / Pi, embedded constraints) and benefits from a literal mapping. Inverting in TypeScript / Python codegen for HMI / analyst is cheap.
Implications: family (slot 4 of every device-scoped topic) carries the family classification (measurements | commands | system) — orthogonal to the action axis. Both are needed: action distinguishes publisher from subscriber; family distinguishes data direction (device-emitted vs broker-imperative vs broker-internal). MQTT bindings (QoS, retain) are inferred from family per §11; per-channel bindings.mqtt blocks override only for exceptions.
Decision: MVP uses a single MQTT username/password from template-secrets.env. Topic ACLs are open. Roles, mTLS, and JWT auth are deferred to v2.
Rationale: Broker is per-deployment private (per ADR-001 — CFN or air-gapped ISO), bounding blast radius. Role-based separation can be added without changing topic structure or payload format.
Implications:
- A compromised device could publish fake telemetry from another device. Acceptable for MVP private-broker deployments. Not acceptable once a multi-tenant or partner-integration scenario emerges — at which point ADR-003 supersedes section 13.
Decision: ems-device-api exposes runtime device CRUD endpoints. Adding, removing, or updating a device — instantiating an existing template at a new device_id, swapping a connection, retitling a display_name, or deleting a device — is a device-api operation, not a redeployment. Each successful mutation regenerates the AsyncAPI v3 spec, bumps semver per §10, persists the new spec, and publishes system/topology_changed { ts, version }. Bulk delivery of a fresh DTM via POST /topology (platform-api on first install or major upgrade) remains supported and is the same regenerate-and-broadcast operation applied to a wholesale replacement.
Rationale:
- Operators commission, repair, and replace equipment at runtime. Forcing every change through a redeploy cycle adds operational friction without a safety benefit, since the spec-regen +
topology_changedmechanism (§10) already handles consumer reconciliation correctly. - Template authoring is the part that needs PR-gating (§7) — adding a new measurement vocabulary item is an engineering change. Template instantiation (using a template that already exists) is a deployment-state change and belongs in the runtime surface.
- The two operations (POST /topology blob and per-device PUT/DELETE/POST) collapse to the same downstream effect: persist the new graph, regenerate the spec, broadcast. Symmetry simplifies consumers — they only react to
topology_changed.
Scope of dynamic operations:
| Operation | Allowed dynamically | Notes |
|---|---|---|
| Add device using existing template | yes | Must reference a template already present in templates_used or in the bundled template set |
| Remove device | yes | Cascades to children when the device is a parent (§7); refuses if the device is referenced by a bus.members[].device_id until the bus is updated |
Update display_name |
yes | Patch-level spec bump (§10) |
Update connection (host/port/unit_id) |
yes | Patch-level — bindings live in x-source per §4; spec regenerates with new metadata |
Update parent (re-parent) |
yes | Validates parent-chain has no cycles |
Add/remove extra_measurements on a device |
yes | Minor spec bump (additive measurement on that device's channels) |
| Introduce a new template at runtime | no | Template authoring is PR-gated (§7); runtime CRUD references templates by bare slug and rejects unknown refs |
| Modify a template (add a measurement, change unit) | no | Same — template edits land via PR to edp-api/device_templates/ |
Implications:
ems-device-apiowns referential integrity at runtime: every CRUD validates template ref, parent chain (acyclic), andbus.members[]consistency.- AsyncAPI semver discipline (§10) applies per CRUD operation. A single PUT can be patch (rename) or minor (add device); the regen pipeline computes the bump from the diff.
system/topology_changedis fired exactly once per successful mutation; transactional CRUD groups multiple changes into one broadcast.ems-hmiis not a CRUD client. It remains read-only against/topologyand/asyncapifor the module browser, SLD rendering, and live telemetry. It does not call POST/PUT/DELETE on devices. Dynamic CRUD endpoints are invoked byplatform-api(bulk delivery), commissioning workflows, and integrator/operator tooling outside the HMI surface. Authorization model belongs in a future ADR-004.- Persistence layer must retain history: every CRUD produces a new versioned topology row so the AsyncAPI version monotonically increases and audit reconstruction is possible.
- Single contract end-to-end — gateway, line-controller, HMI, analyst all generate from one AsyncAPI spec
- Topic strings stay stable across firmware iteration, customer renames, and unit-vocabulary extensions
- Operator debuggability matches industrial-SCADA expectation
- Two-family model collapses three previously incompatible draft conventions into one
- Industrial-historian egress (PI, Ignition) needs a quality-stitching adapter
- Adding a unit is not free — spec change →
topology_changed→ consumer re-fetch - Template governance becomes a real engineering surface (PR review, versioning discipline)
- Drift between gateway code and
x-sourcebinding metadata in spec — mitigated by gateway-codegen-from-spec - Template-vocabulary bikeshedding moves from per-PR to per-template-design — same problem, different cadence
- MVP basic auth is a known short-term gap; ADR-003 owes the role-based design
- Rejected: customer-owned topic namespace beats vendor prefix; per-deployment broker means
deployment_uuidis redundant on the wire
- Rejected: operator debuggability lost; SCADA convention favors unit-in-topic; broker-level filtering by unit becomes consumer-side after spec lookup
- Rejected: quality is recoverable via time-joined
status; wire bytes saved on every message
- Rejected: parser nightmare for wildcard subscriptions; broker ACL hooks become payload-dependent
- Rejected: bikeshedding multiplies; no single canonical source for downstream codegen
This ADR should be reviewed:
- When the unit vocabulary needs extension — add via patch-level spec bump, document the new unit
- When a new template is proposed — template governance kicks in, not this ADR
- When auth scope changes — new tenant model, partner integration, or compliance requirement triggers ADR-003 supersession of section 13
- When industrial-historian egress is implemented — verify the time-join quality pattern holds in practice
- ADR-001: System Architecture
- AsyncAPI 3.0 Specification
- TimescaleDB Best Practices for Time Columns
- Sparkplug B Specification — referenced for SCADA-vocabulary alignment