Reports › Contract docs › diagnostics-record-v0

Diagnostics record v0

2026-10-22. Contract: contract/record.py (SCHEMA = "diagnostics-record/0"). Tests: tests/test_record.py. Frozen for the IO v1 build: additive changes only (see Versioning). Evidence: docs/research/diagnostics-schema-ros.md and docs/research/diagnostics-schema-standards.md, plus the primary sources re-checked for this spec (last section).

The answer

ros2_medkit and the other modern efforts did not change the message. diagnostic_msgs/DiagnosticStatus is byte-identical on Humble, Jazzy and Rolling. Its only field-level change since 2009 is the STALE=3 constant (Indigo, 2014). What changed is everything around it. medkit turns /diagnostics into SOVD-style faults with a lifecycle. Autoware builds a graph over the untouched leaf. ros2_control, DroneCAN and MAVLink carry mode and typed state beside it. The field converged on the same moves:

v0 is exactly that. It keeps ROS's five fields unchanged, adds nine optional fields beside them, and adds four sibling records.

Eight lessons, and who learned them

  1. Wrap the wire format; don't fork it. - Seventeen years and one added constant. The 2025 proposal for more levels (common_interfaces #268, still open) stalled because inserting values renumbers recorded bags and breaks the aggregator's numeric order. - Every newer project kept the message and built beside it: medkit Fault/FaultEvent/Snapshot, Autoware DiagGraphStruct/DiagGraphStatus, ros2_control HardwareStatus. - SOVD did the same with UDS. AUTOSAR R24-11 calls it "UDS as a subset".
  2. level carried three things. Split severity, freshness and readiness. - Putting STALE (3) above ERROR (2) on one axis made the ROS aggregator report ERROR for any partly-stale group (#297, 2023). The fix, #593, was merged 2026-05-20 with the rule "WARN/ERROR always beats STALE". - Today GenericAnalyzerBase::report() on the ros2 branch still turns a partly-stale group into ERROR: one codebase, two rules. Whether WARN should beat STALE is still open (#658). - DroneCAN NodeStatus (health + mode + uptime), Cyphal, MAVLink (MAV_STATE vs SYS_STATUS health), Redfish (Health vs State) and ros2_control (health_status vs operational_mode) always kept them apart.
  3. "Can't tell" is never green. Staleness is judged by the observer against a declared period. - A zero value that reads green is a trap; the zero value must be UNKNOWN. ros2_control HEALTH_UNKNOWN = 0, MAVLink MAV_STATE_UNINIT = 0, K8s ("absence of a condition = Unknown") and UDS (0x50 after a clear: only the "not completed" bits) all avoid it. - Freshness needs a promise to judge against: DroneCAN's OFFLINE_TIMEOUT_MS = 3000 against a ≤ 1 s period, Entity-Sensor MIB ValueUpdateRate (2002), Redfish SensingInterval, Greenwave's expected_frequency, rosgraph_monitor's Deadline QoS. Lichtblick computes staleness client-side from the array stamp, so an unstamped array is instantly stale (#341).
  4. Every non-OK report needs a stable machine code beside the human message. - K8s made reason required on metav1.Condition (KEP-1623). Redfish's only required Condition field is MessageId. SOVD faults key on code (from the UDS DTC). medkit has fault_code; DroneCAN vendor_specific_status_code. - Without a code you cannot count, dedupe, debounce, suppress or translate.
  5. Status, event and fault are three shapes. - "A status is information; a fault is a model you can query and act on" (medkit, "Why we did not extend /diagnostics", 2026-09). - UDS/AUTOSAR DEM keep the current test result (testFailed) apart from the stored fault (confirmedDTC), with debounce, healing, aging and freeze frames. Apex.Grace has the same chain: monitor → DFM → DTC. - The "more levels" fight was mostly people wanting events (INFO, NOTICE) inside a status. - Field-tested traps: time and count debounce are not interchangeable. Count debounce never confirms or heals an edge reporter, which is documented in medkit's README. A check that did not run must not count as a pass.
  6. Type the numbers, or every consumer re-parses strings. - ros2_control packs "Avg: … [min - max] us, StdDev: …" into one value. The InfluxDB exporter guesses which values are numbers. Greenwave relies on unversioned magic keys. - Your own bam_diagnostics flattened its typed thresholds into key strings ("mean_max [50.0, 60.0]" → "52.000"). - Redfish Sensor (UCUM units, thresholds in the same unit, ReadingTime), Nagios perfdata (25 years) and MAVLink (units on every field) carry a typed value. OpenSOVD started with string pairs and added a typed status.
  7. Identity is minimal, repeatable across observers, and has two kinds of parent. - hardware_id has been used as a serial, as a node FQN and as an empty string (#520), and tools key on it, so an empty first message duplicates a row (#666). The aggregator rewrites name. - OTel's entity model: "Two independent observers that report the same entity MUST be able to supply identical values for all identifying attributes". On this rack the D405's USB serial ≠ its SDK serial. - Home Assistant (Aug 2026) split via_device_id (connectivity) from child devices (composition). Children carry no connectivity or hardware identity of their own. - Send identity once, not in every report: Autoware struct vs status, DroneCAN GetNodeInfo vs NodeStatus.
  8. Rollup is the observer's job, follows a written rule, collapses knock-on failures, and respects black boxes. - Redfish separates Health from HealthRollup; RFC 4268 separates raw from computed state. OpenBMC removed its rollup in 2024 because it is "nontrivial to get correct" and Redfish gives no algorithm. - Root cause, not symptoms: Nagios marks hosts behind a DOWN router UNREACHABLE, AUTOSAR DEM does not store a fault under a failed parent, and ROS's own aggregator has the two rules above. - Zach's rule (2026-10-22) sets whose judgement a level is: each black box self-reports what a failure means for that box. The host's rollup is only a hint.

The shapes

Shape One sentence Sent Borrowed from
DiagnosticStatus One check on one entity Every period, or on change ROS DiagnosticStatus, DroneCAN NodeStatus, Redfish Sensor
Entity What and where a thing is, and the actions its owner registered Once, and on change HA device registry, OTel entity, Autoware struct, SOVD entity
Event Something happened When it happens MAVLink events, Cyphal Record, Redfish MessageId + MessageArgs
Fault The host's memory of one (entity, code) failing On lifecycle transitions ros2_medkit Fault, UDS DTC status, AUTOSAR DEM
Action A utility the owner registers and runs With its Entity SOVD operations, HA button/update, printer-driver utilities
driver / OS observer ── Entity (once) ─────────────────────────────┐
        │                                                           ▼
        └── DiagnosticStatus (periodic) ──► host: effective_level ──► summarize ──► rows: own level + rollup hint
                                                  └──► FaultManager ──► Fault + Event (FaultConfirmed, FaultHealed)
stock view:  to_diagnostic_status()  ──►  rqt · Lichtblick · Foxglove · medkit bridge

Field tables

DiagnosticStatus (one check on one entity)

Field Type Required Why it is here (lesson · source) Stock DiagnosticStatus
level unknown \| ok \| warn \| error yes, default unknown ROS level without STALE (derived), with UNKNOWN as the zero value (L2, L3) ok→0 warn→1 error→2 unknown→3 (STALE)
name str yes ROS name: the check as a person reads it, REP-107 component: subcomponent name
message str yes, may be "" ROS message: a rendering of code + values (K8s, Redfish Message) message
hardware_id str for physical things ROS hardware_id in REP-107's original sense: the repeatable identifier (L7) hardware_id
values KeyValue[] no ROS values: strings for people ("the intended consumer is a person", REP-107) values, first
measurements Measurement[] no Typed numbers with unit and limits (L6) Appended to values as key: "37.2 V"
path str yes, for native records Where it is: the entity key (L7). Machines key on it, never on name stripped
code str, PascalCase, ≤ 64 when level != ok Stable machine reason (L4) stripped
mode operational \| initialization \| maintenance \| software_update \| offline no Readiness, not severity: DroneCAN MODE_* names (L2) stripped
stamp float, Unix s recommended Source time of the evaluation: Redfish ReadingTime. ROS has only one stamp per array stripped (the array header keeps one)
received float, Unix s set by the observer Observer's clock, which staleness uses: OTel ObservedTimestamp stripped
expected_period_s float or None for periodic checks The promise staleness is judged against (L3); your dx_tick_hz stripped
uptime_s float no Exposes a restart or brown-out that a level hides: DroneCAN/Cyphal uptime stripped
source str no Who reported it, when two observers report on one entity: medkit source_id, your source_component stripped
caused_by path[] composites (v0.1 addendum) The members whose own reports caused this verdict, so one fact counts once (AUTOSAR DEM consecutive fault, Icinga --root-cause) one value caused_by, appended last

DiagnosticStatus.OK / WARN / ERROR / UNKNOWN exist as class constants, so code reads like rclpy. There is deliberately no DiagnosticStatus.STALE.

Measurement (a typed value)

Field Type Why
key str Unique across values and measurements (validate checks)
value float, int, bool or None None = could not be read now (Nagios U, HA unavailable). A sensor that is lying is the record's level + code, not a value flag
unit UCUM string Redfish ReadingUnits and OTel use UCUM: V, A, Cel, Hz, s, ms, %, 1
stamp float When this value was read, if not the record's stamp: the "last read 18:13 by rack_inventory" case from V6's Reading
warn_low, warn_high, error_low, error_high float Limits in the value's unit (Redfish Caution/Critical, IPMI nc/cr, your DiagnosticValue(warn, error)). Measurement.level: a value strictly beyond a limit trips it; equal is inside. Unreadable or NaN = unknown

Entity (what and where; sent once and on change)

Field Type Why
path str The key every status, event and fault refers to. Position in the tree; it changes when a device is replugged
name, kind str Label and icon (host / port / device / bus / node / part / stream)
via path or None Connectivity parent: the port, hub, adapter or bus it is reached through (HA via_device_id). This is the knock-on path
part_of path or None Composition parent: the product it is a logical part of (HA child devices). One parent edge per entity
hardware_id str What it is: repeatable by every observer. Parts carry none
identity {manufacturer, model, serial_number, hw_version, sw_version} Descriptive, may change, not identifying (HA device_info, OTel descriptive attributes). Parts carry none
owner str Who registered it and executes its actions
actions Action[] See Actions

Event (something happened)

Field Type Why
code str, PascalCase Plugged, Unplugged, ReEnumerated, Restarted, FaultConfirmed, …
stamp float Required
severity info \| warn \| error Log severity. INFO lives here, never in a status level
message, args str, dict The message renders code + args (Redfish MessageArgs, HA translation placeholders)
path, hardware_id str hardware_id ties an unplug to the device after its path is gone
seq, source int, str Per-source sequence: a jump means events were lost (MAVLink events)

Fault (host memory; never sent by a driver)

Field Type Why
path, code str The key: (entity, code), not a global code. medkit keys globally, so a code shared by two entities only shows on its global route
severity warn \| error Level of the latest failing report
status pending \| confirmed \| healed \| cleared medkit/SOVD names; see the lifecycle
active bool Failing in the latest evaluated report (UDS testFailed). A confirmed fault can be idle
occurrence_count int Confirmed occurrences, counted on edges, not reports (medkit)
first_occurred, last_occurred, last_passed float When the current occurrence started, last failed, last passed (medkit Fault)
message, hardware_id str
snapshot dict Freeze frame: to_dict() of the status that confirmed it (DEM freeze frame, medkit #668)

Action (registered by the owner)

The same dataclass V6 and the three proposed drivers already use, moved here. contract/diagnostics.py re-exports it, so there is one definition.

Field Why
name, title, description Machine name (clear_faults), button label, what it does
danger query (reads only) · interrupts (drops the device for a moment) · changes (writes state)
scope Which of the driver's entities it attaches to (device, node): a driver registers once for many entities
confirm Confirm text with {name}, {id}, {serial}, {latest} placeholders
preconditions (check, text) pairs the host evaluates passively: port_free, bus_lock_free, camera_free, board_answers, update_available
kind Gate class: passive / touches / changes

Rules

1. Levels

2. Staleness

3. Composition: black boxes self-report (Zach, 2026-10-22)

"In black-box design it's up to each lower-level black box to self-report what it means in the context of THAT black box. You can group those entities into larger entities and make your own diagnostics wrapper: say a robot with 8 legs, 1 leg says failure, the spider can still report warning."

summarize(entities, statuses, now) returns one Summary per entity:

Case (tests) Result
Spider: own WARN (LegFailed), leg 3 ERROR spider level WARN, rollup ERROR from leg 3. A grouping cell above shows ERROR (worst below)
fdcanusb XXXXXXXX: adapter OK, bus ERROR NoAck, nodes stale/unknown/NoReply host rollup = ERROR from the bus, counts {error: 1}, four knock-ons. The adapter row stays OK with an ERROR hint
Bus stale, node 1 stale, node 2 fresh ERROR (fault 33) node 2 is its own cause ({stale: 1, error: 1}); only node 1 collapses

Deliberately unlike Autoware. Autoware decides context in one central configured graph: and/or/remap per operation mode, with hazard computed from leaf level × mode root. v0 puts the judgement in the black box that knows what a failure means for it. A composite is a black box like any leaf (closure under composition). Where you want a judgement at some level, write a wrapper there. A grouping node with no wrapper has no judgement, so it shows worst below.

Surfaces: a tree row shows level plus the rollup hint. The top-bar chip answers "does anything need me?", so it reads the root's rollup.

4. Identity and the two parent edges

5. Faults (FaultManager)

6. Actions

7. The stock projection

8. Versioning

9. What this record is not

What bam_diagnostics already got right (like_keras archive, lk/archive/common/diagnostics, lk/archive/msgs/diagnostic)

Right, and kept:

The traps it hit, which v0 closes:

Deliberately left out

Left out Why Revisit when
More severity levels (INFO, NOTICE, CRITICAL, FATAL) ROS #268 stalled on exactly this; impact is contextual (the black box judges); INFO/NOTICE are events Never for status; events already have info
STALE as an authored level The observer derives it; authoring it put two axes on one field —
A central configured graph (Autoware and/or/remap, hazard per mode) Zach's black-box rule: the judgement lives in the box that knows; write a wrapper A safety case needs a system-level verdict (that also needs a non-diagnostics channel)
Per-value validity enum (ok/unavailable/invalid/untested) value=None is unavailable; "invalid" is the record's level + code; "untested" is unknown A sensor needs to say "lying" per value
Hysteresis and dwell on limits (Redfish 2023.1, IPMI) The producer applies them when it evaluates; the host does not re-evaluate limits in v0 The host starts re-checking limits itself
Flap detection (Nagios) Debounce plus the flap-coalescing journal cover v0 A flapping check spams faults
since / lastTransitionTime on status Transitions are Events; "error since" is the Fault's first_occurred A late-joining consumer needs it without history
config_generation (K8s observedGeneration) The rack's config interlock is real, but one user; carry it as a value A second subsystem needs "judged against which config"
diagnostic vs config value category (HA entity_category) Config shows in the driver's read-only config view, not in status Status values and config share one table
Declared expected checks/entities (aggregator num_items_expected, V5 station board) Host or station config, not the record The station board lands
Embedding the full record in stock values for lossless ROS transport Zach chose a stripped stock view; v0 travels as JSON lines A ROS-only link must carry v0 end to end
Translation keys (HA translation_key, Redfish registries) code makes translation possible later; English messages now A second language or a fleet UI
Fault aging, operation cycles, several fault memories (SOVD scope), near-miss series, correlation clusters The fault manager's next steps, not record fields Faults persist across restarts
Source authentication (SROS2, medkit hash-chained audit log) Local, single-user Faults drive remote actions
Index-aligned compact status (Autoware) Premature at ~1 Hz JSON Bandwidth matters (radio links)
A sequence number on status Status is level-based; only events need gap detection —

Open questions (recommendation · default if you say nothing)

  1. A grouping node over a wrapper. Your rule, taken literally, makes a grouping node with no wrapper show worst-below. So a cell over the spider shows ERROR while the spider says WARN. - Recommend keeping it: a folder cannot know what the spider tolerates, and the alternative (a folder shows its children's headlines) would let a self-only box hide its children. With that alternative the fdcanusb adapter (USB fine, OK) would hide its dead bus from everything above it. - If you want a judgement at the cell, write a cell wrapper. - Default: as implemented.
  2. WARN vs can't-tell. Recommend warn < unknown < stale < error (fail-safe), the opposite of ROS #593. Default: as implemented; one line to flip.
  3. The stale rule. max(3 × period, 5 s) on the receive clock. Default: that.
  4. medkit interop. UNKNOWN goes out as STALE, which medkit's bridge turns into CRITICAL. Recommend configuring medkit (#668), not changing the projection. Default: STALE.
  5. Schema id. The v1 build plan's plugin describe says "io-diagnostics-record/0". The record is not IO-specific. Default: plugins send record.SCHEMA ("diagnostics-record/0").
  6. Debounce defaults. WARN 3 reports, ERROR at once, heal 3; time rules for edge reporters. Default: those.
  7. Faults to stock tools. Recommend not publishing faults as DiagnosticStatus; medkit can derive its own from the stock stream. Default: faults stay in the host.

Evidence re-checked for this spec (2026-10-22, primary sources)

v0.1 (2026-10-22, additive)

The decisions are frozen in docs/actions-vocabulary-v0.1.md; this section says what changed in the contract and how a reader tells.

v0.1 addendum: caused_by (2026-10-23, additive)

Why: knock-on collapse (rule 3) explains in ONE direction, down the parent edge. That is right for via: a dead adapter explains its silent boards. It is backwards for a composite, whose verdict is caused by its members. Three probes against this code showed it (reports/diagnostics-rollup-proposals-2026-10-23.html, "The barrier"): the rig row and CAN 15's own report counted one missing board twice; a spider at ERROR filed its failed leg as its own symptom; the Computer's checks written on the host swallowed the rack below.