diff --git a/.github/workflows/pixi.yml b/.github/workflows/pixi.yml
index 7c16cad0..b7e16686 100644
--- a/.github/workflows/pixi.yml
+++ b/.github/workflows/pixi.yml
@@ -16,7 +16,9 @@ jobs:
runs-on: ubuntu-latest
# Experimental - don't block development while RoboStack support matures
continue-on-error: true
- timeout-minutes: 45
+ # The build step alone is ~32 min of this, and the job has been landing at 37-40 min for
+ # weeks with about 3 min of run-to-run variance, so 45 left no room for a package growing.
+ timeout-minutes: 60
defaults:
run:
shell: bash
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 7266c9ff..df49ab2b 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -309,9 +309,24 @@ jobs:
- name: Extend test timeouts for ASan overhead
run: |
- find build/ -name "CTestTestfile.cmake" \
- -exec sed -i 's/TIMEOUT "60"/TIMEOUT "180"/g' {} + \
- -exec sed -i 's/TIMEOUT "120"/TIMEOUT "360"/g' {} +
+ # Multiply EVERY declared CTest timeout by three, whatever it is. The
+ # x3 factor is the one the two literal rewrites this replaced encoded
+ # (60 -> 180, 120 -> 360), so no test's effective budget changes here;
+ # what changes is that a test declared at any OTHER value is no longer
+ # silently left on its native budget under a sanitizer that makes it
+ # several times slower. A fixed pair of literals goes stale the moment
+ # a budget is raised for good reasons, and the failure it produces is
+ # the worst kind: ctest kills the process and the test's own output
+ # dies with it, so the run reports a timeout and no test name.
+ find build/ -name "CTestTestfile.cmake" -exec \
+ perl -pi -e 's/\bTIMEOUT "(\d+)"/sprintf(q{TIMEOUT "%d"}, $1 * 3)/ge' {} +
+ # Print what the tests will actually run with, so a sanitizer job that
+ # is killed on time can be read against its real budgets. `cat` first
+ # so grep sees one stream: its exit status then means "the rewrite had
+ # nothing to act on", which under pipefail correctly fails the step,
+ # rather than "one -exec batch happened to hold no timeouts".
+ find build/ -name "CTestTestfile.cmake" -exec cat {} + \
+ | grep -oE 'TIMEOUT "[0-9]+"' | sort -t'"' -k2 -n | uniq -c
- name: Run unit + integration tests with ASan + UBSan
timeout-minutes: 30
@@ -414,9 +429,12 @@ jobs:
- name: Extend test timeouts for TSan overhead
run: |
- find build/ -name "CTestTestfile.cmake" \
- -exec sed -i 's/TIMEOUT "60"/TIMEOUT "180"/g' {} + \
- -exec sed -i 's/TIMEOUT "120"/TIMEOUT "360"/g' {} +
+ # Same generic x3 rewrite as the ASan job - see the comment there for
+ # why it is not a list of literal values.
+ find build/ -name "CTestTestfile.cmake" -exec \
+ perl -pi -e 's/\bTIMEOUT "(\d+)"/sprintf(q{TIMEOUT "%d"}, $1 * 3)/ge' {} +
+ find build/ -name "CTestTestfile.cmake" -exec cat {} + \
+ | grep -oE 'TIMEOUT "[0-9]+"' | sort -t'"' -k2 -n | uniq -c
- name: Run unit + integration tests with TSan
timeout-minutes: 30
diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/CMakeLists.txt b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/CMakeLists.txt
index 51779c26..8c0fbdaa 100644
--- a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/CMakeLists.txt
+++ b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/CMakeLists.txt
@@ -28,6 +28,9 @@ find_package(ament_cmake REQUIRED)
find_package(ros2_medkit_gateway REQUIRED)
find_package(ros2_medkit_msgs REQUIRED)
find_package(rclcpp REQUIRED)
+# The reused gateway parameter transport includes rcl_interfaces message headers directly;
+# it built only through rclcpp's transitive exports.
+find_package(rcl_interfaces REQUIRED)
find_package(nlohmann_json REQUIRED)
find_package(lifecycle_msgs REQUIRED)
find_package(tf2_msgs REQUIRED)
@@ -82,7 +85,7 @@ add_library(ros2_medkit_graph_watchdog MODULE
${DETECTOR_SRCS})
target_include_directories(ros2_medkit_graph_watchdog PRIVATE include ${GATEWAY_SRC_INCLUDE_DIR})
medkit_target_dependencies(ros2_medkit_graph_watchdog
- ros2_medkit_gateway rclcpp ros2_medkit_msgs lifecycle_msgs tf2_msgs geometry_msgs)
+ ros2_medkit_gateway rclcpp rcl_interfaces ros2_medkit_msgs lifecycle_msgs tf2_msgs geometry_msgs)
target_link_libraries(ros2_medkit_graph_watchdog nlohmann_json::nlohmann_json)
install(TARGETS ros2_medkit_graph_watchdog LIBRARY DESTINATION lib/${PROJECT_NAME})
@@ -121,6 +124,8 @@ if(BUILD_TESTING)
find_package(ament_cmake_gtest REQUIRED)
find_package(rosgraph_msgs REQUIRED)
+ # test_param_drift_integration.cpp includes rcutils/logging.h directly.
+ find_package(rcutils REQUIRED)
ament_add_gtest(test_warmup_tracker test/test_warmup_tracker.cpp)
target_include_directories(test_warmup_tracker PRIVATE include ${GATEWAY_SRC_INCLUDE_DIR})
@@ -187,12 +192,14 @@ if(BUILD_TESTING)
target_link_libraries(test_reliability_gate nlohmann_json::nlohmann_json)
medkit_set_test_domain(test_reliability_gate)
- # LifecycleShutdownSuppressor: needs a REAL ReliabilityGate (departed_lifecycle_state_of()
- # reads live from ctx.gate) - same real-Node/ROS_DOMAIN_ID rationale as test_reliability_gate
- # above, unlike test_allowlist_suppressor (pure interface, no gate needed).
+ # Pure matching logic over hand-built inputs: no rclcpp::Node, so no domain is needed.
ament_add_gtest(test_orphan_policy test/test_orphan_policy.cpp)
target_include_directories(test_orphan_policy PRIVATE include)
+ ament_add_gtest(test_param_drift_policy test/test_param_drift_policy.cpp)
+ target_include_directories(test_param_drift_policy PRIVATE include)
+ target_link_libraries(test_param_drift_policy nlohmann_json::nlohmann_json)
+
ament_add_gtest(test_qos_policy test/test_qos_policy.cpp)
target_include_directories(test_qos_policy PRIVATE include)
medkit_target_dependencies(test_qos_policy rmw) # rmw is transitively found via rclcpp; no find_package needed
@@ -202,9 +209,10 @@ if(BUILD_TESTING)
medkit_target_dependencies(test_aggregated_fault ros2_medkit_gateway rclcpp ros2_medkit_msgs)
target_link_libraries(test_aggregated_fault nlohmann_json::nlohmann_json)
- # Pure suppressor-chain interface: no raise_fault/clear_fault calls (no ReliabilityGate
- # sources needed), no live rclcpp::Node, so no ROS_DOMAIN_ID is required - same as
- # test_qos_policy above.
+ # Real DDS endpoints read through the live graph API, so each of these takes a domain of its
+ # own via medkit_set_test_domain. The two that ALSO register a fake /fault_manager/report_fault
+ # service additionally share a RESOURCE_LOCK, because they spin real nodes for many seconds and
+ # running them concurrently on this box starves the discovery each one waits on.
ament_add_gtest(test_qos_mismatch_integration
test/test_qos_mismatch_integration.cpp
src/detectors/qos_mismatch_detector.cpp
@@ -232,6 +240,27 @@ if(BUILD_TESTING)
medkit_set_test_domain(test_orphan_integration)
set_tests_properties(test_orphan_integration PROPERTIES TIMEOUT 120 RESOURCE_LOCK graph_watchdog_integration_domain)
+ ament_add_gtest(test_param_drift_integration
+ test/test_param_drift_integration.cpp
+ src/detectors/param_drift_detector.cpp
+ src/detector_registry.cpp
+ src/reliability_gate.cpp
+ src/lifecycle_watcher.cpp
+ ${LIFECYCLE_STATE_READER_SOURCES}
+ ${PARAMETER_TRANSPORT_SOURCES})
+ target_include_directories(test_param_drift_integration PRIVATE include ${GATEWAY_SRC_INCLUDE_DIR})
+ medkit_target_dependencies(test_param_drift_integration
+ ros2_medkit_gateway rclcpp rcl_interfaces rcutils ros2_medkit_msgs lifecycle_msgs)
+ target_link_libraries(test_param_drift_integration nlohmann_json::nlohmann_json)
+ medkit_set_test_domain(test_param_drift_integration)
+ # 300 s, not 120: several cases here are paced off the detector's own attempt and tick horizons
+ # (sixty failed reads, sixty unarmed ticks, a read that answers past the per-read bound) and each
+ # of those costs real seconds that no budget knob can compress away. A timeout the suite can
+ # outgrow kills the process before gtest prints which case failed, which is the one thing a
+ # timeout must never do to a diagnostic suite.
+ set_tests_properties(test_param_drift_integration PROPERTIES
+ TIMEOUT 300 RESOURCE_LOCK graph_watchdog_integration_domain)
+
# === QoS-mismatch e2e (launch_testing) ===
#
# Acceptance gate: proves GRAPH_QOS_MISMATCH raises and clears through the
@@ -257,6 +286,77 @@ if(BUILD_TESTING)
ENVIRONMENT "GATEWAY_TEST_PORT=19130;ROS_DOMAIN_ID=89;GRAPH_WATCHDOG_PLUGIN_PATH=${_WATCHDOG_E2E_SO}"
RESOURCE_LOCK graph_watchdog_e2e_domain)
+ # param_drift e2e: the detector's DEFAULT mode. The config-plumbing scenarios below all set
+ # baseline: false and none of them asserts a clear, so without this the self-capturing behaviour
+ # operators actually get, and the whole healing story the README documents, had no e2e at all.
+ # 300 s, not 240: the file's own deadlines are now the arming gate (60), the silent-capture
+ # window (15), the raise poll (60), the still-raised check (30) and the heal poll (60), and 240
+ # no longer covers their sum. A budget shorter than the internal deadlines is killed by ctest
+ # before any of the messages those deadlines exist to print reaches the log, which turns every
+ # one of them into "timed out".
+ add_launch_test(test/e2e/test_param_drift_e2e.test.py TIMEOUT 300)
+ set_tests_properties(test_e2e_test_param_drift_e2e.test.py PROPERTIES
+ LABELS "integration;e2e"
+ ENVIRONMENT "GATEWAY_TEST_PORT=19170;ROS_DOMAIN_ID=89;GRAPH_WATCHDOG_PLUGIN_PATH=${_WATCHDOG_E2E_SO}"
+ RESOURCE_LOCK graph_watchdog_e2e_domain)
+
+ # Round-trip cost e2e: what ONE parameter read really costs the node that serves it, counted
+ # at a node that answers the parameter services by hand. param_drift's read budget is charged
+ # in round trips (2 / 3 / 2), every other test in this package proves the arithmetic GIVEN
+ # those numbers, and the stand-in transport they use counts CALLS - so nothing else here can
+ # notice a round trip being added to or removed from Ros2ParameterTransport. Deliberately runs
+ # with param_drift OFF: the reads are driven over the configurations endpoint so each count
+ # belongs to exactly one transport call.
+ # 300 s, not 180: this test's own retry deadlines (discovery, first contact against each of the
+ # two probes) plus the single-shot measurements between them can legitimately add up to more
+ # than 180 on a slow-discovery run - which is the run it exists to diagnose. A timeout shorter
+ # than the internal budgets kills the process before any of those diagnostics print, so the
+ # failure reads as "test timed out" and says nothing about round trips.
+ add_launch_test(test/e2e/test_param_roundtrip_e2e.test.py TIMEOUT 300)
+ set_tests_properties(test_e2e_test_param_roundtrip_e2e.test.py PROPERTIES
+ LABELS "integration;e2e"
+ ENVIRONMENT "GATEWAY_TEST_PORT=19180;ROS_DOMAIN_ID=89;GRAPH_WATCHDOG_PLUGIN_PATH=${_WATCHDOG_E2E_SO}"
+ RESOURCE_LOCK graph_watchdog_e2e_domain)
+
+ # === Config-plumbing e2e (launch_testing) ===
+ #
+ # Proves nested per-detector config reaches a live detector through the REAL gateway
+ # (extract_plugin_config -> detector_config.hpp -> ParamDriftConfig::from_json). No C++ test
+ # above can give that proof: they all call configure() directly with hand-built JSON and
+ # never touch the delivery path.
+ #
+ # One source file, three CTest targets. The plugin reads its config once, at set_context()
+ # time, so proving the nested `expect` reader and the nested `mode` reader needs separate
+ # gateway launches rather than separate test methods in one. WATCHDOG_E2E_SCENARIO selects
+ # which launch and which assertions run in each process.
+ add_launch_test(test/e2e/test_config_plumbing_e2e.test.py
+ TARGET test_config_plumbing_e2e_positive
+ TIMEOUT 180)
+ set_tests_properties(test_config_plumbing_e2e_positive PROPERTIES
+ LABELS "integration;e2e"
+ ENVIRONMENT "GATEWAY_TEST_PORT=19140;ROS_DOMAIN_ID=89;WATCHDOG_E2E_SCENARIO=positive;GRAPH_WATCHDOG_PLUGIN_PATH=${_WATCHDOG_E2E_SO}"
+ RESOURCE_LOCK graph_watchdog_e2e_domain)
+
+ add_launch_test(test/e2e/test_config_plumbing_e2e.test.py
+ TARGET test_config_plumbing_e2e_mode_off
+ TIMEOUT 180)
+ set_tests_properties(test_config_plumbing_e2e_mode_off PROPERTIES
+ LABELS "integration;e2e"
+ ENVIRONMENT "GATEWAY_TEST_PORT=19150;ROS_DOMAIN_ID=89;WATCHDOG_E2E_SCENARIO=mode_off;GRAPH_WATCHDOG_PLUGIN_PATH=${_WATCHDOG_E2E_SO}"
+ RESOURCE_LOCK graph_watchdog_e2e_domain)
+
+ # Third launch: `mode: off` written BARE, which the ROS parser types as a YAML 1.1 boolean
+ # rather than a string. That is the form operators write, and a string-only reader silently
+ # keeps the detector raising.
+ add_launch_test(test/e2e/test_config_plumbing_e2e.test.py
+ TARGET test_config_plumbing_e2e_mode_off_yaml_bool
+ TIMEOUT 180)
+ set_tests_properties(test_config_plumbing_e2e_mode_off_yaml_bool PROPERTIES
+ LABELS "integration;e2e"
+ ENVIRONMENT
+ "GATEWAY_TEST_PORT=19160;ROS_DOMAIN_ID=89;WATCHDOG_E2E_SCENARIO=mode_off_yaml_bool;GRAPH_WATCHDOG_PLUGIN_PATH=${_WATCHDOG_E2E_SO}"
+ RESOURCE_LOCK graph_watchdog_e2e_domain)
+
ros2_medkit_relax_vendor_warnings()
endif()
diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/README.md b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/README.md
index 2225c130..638af37f 100644
--- a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/README.md
+++ b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/README.md
@@ -7,7 +7,8 @@ Detectors read the graph and raise faults through a `ReportFault` service client
gateway node; the faults surface via FaultManager on the gateway `/faults` API.
This package carries the plugin skeleton, the central reliability gate that holds raises
-until the graph has quiesced, and two detectors, `qos_mismatch` and `orphan`. The remaining
+until the graph has quiesced, and three detectors, `qos_mismatch`, `orphan` and
+`param_drift`. The remaining
silent-fault classes land in follow-up changes, each against its own issue; their fault
codes are already reserved in the frozen `GRAPH_*` namespace (see "Fault codes").
@@ -72,6 +73,17 @@ differs only in its numeric field and the rule above already spares it.
`/robot/scan`, is six edits apart, so no budget that is still specific will reach it. Do not
rely on this detector for that class.
+### `param_drift` keys
+
+| Key | Type | Default | Meaning |
+|-----|------|---------|---------|
+| `mode` | string \| bool | `raise` | As above. A bare `off` arrives as a YAML boolean; both forms disable the detector. |
+| `baseline` | bool | `true` | Self-capture the value of every parameter not listed in `expect`, then flag later changes to it. `false` checks only what `expect` pins, and skips reading the full parameter list. |
+| `expect.` | any | - | Absolute expected value for ``, checked on every node that has it. This is the only rule that catches a value that was wrong from the first tick, which self-capture cannot see. A node that does not declare `` answers NOT_FOUND, which is not a drift - so a misspelt name checks nothing at all. Once the sweep has covered every armed app - each one read, or given up on after repeated failed reads - and at least one of them answered, a pin that no app declares is warned about once, naming it. |
+| `ignore` | string[] | `[]` | Parameter-name globs never flagged. Applies to the self-captured set only, so it cannot silence an `expect` pin. A pattern of nothing but `*` matches every name and suppresses self-capture wholesale, which is indistinguishable at runtime from finding nothing; that combination warns at startup. |
+| `max_reads_per_tick` | int | `8` | Parameter-service ROUND TRIPS this detector may spend per tick, round-robin over nodes. Round trips, not calls: one call is several requests at the watched node, and the node pays for each of them. A `baseline` visit is charged 2 (a list, then one batched get) whatever the node's parameter count; each `expect` pin is charged 3 (a list, a get and a descriptor read), or 2 on a node that does not declare it - see the table under "Coverage latency" for what each of those really costs the node. So a self-capture visit spends 2 of the budget and pinning N parameters spends up to 3N. It is an AMORTISED rate, not a per-tick cap: there is no pacing INSIDE a visit, so a node's whole read set is issued back to back and the reader waits out the debt afterwards. Four pins against a budget of 8 fire 12 round trips inside one tick period and then idle for the rest of the next one; what the knob bounds is the sustained rate, not the burst. Accepted range 1..100000; anything else keeps the default and warns. |
+| `prune_grace` | int | `60` | Consecutive sweeps an app may be missing before its finding is dropped: the finding survives `prune_grace` absences and goes on the next one. Injected for every detector at plugin scope and overridable per detector; a value outside 0..3600 is rejected with a warning and this detector falls back to 2, not to the plugin default. The captured BASELINE has a shorter horizon of its own, and that one is NOT configurable - it is the compile-time `kBaselineForgetGrace`: the baseline is dropped on the third consecutive missed sweep, the two the reliability gate absorbs plus one, so an ordinary dropped discovery poll cannot re-capture a node on its already-drifted value. Setting `prune_grace` below 2 pulls that forward onto the sweep the finding itself is dropped on, so the two then go together. |
+
### `qos_mismatch` keys
| Key | Type | Default | Meaning |
@@ -81,6 +93,39 @@ rely on this detector for that class.
| `allowlist` | string[] | `[]` | Subscriber FQNs never reported. Exact match only. |
+## Closing the loop: healing config is required
+
+Every detector here reports level-triggered: it re-raises FAILED while the condition holds
+and emits PASSED on every clean sweep. That is what lets the fault_manager's debounce
+counter walk from CONFIRMED back to HEALED. The fault_manager only counts PASSED events
+toward healing when `healing_enabled` is true for that fault, and it defaults to `false`.
+Without it, a `GRAPH_*` fault that has genuinely cleared stays CONFIRMED forever. Enable
+healing globally on the fault_manager node:
+
+```bash
+ros2 run ros2_medkit_fault_manager fault_manager_node --ros-args \
+ -p healing_enabled:=true \
+ -p healing_threshold:=1 # 2 consecutive clean sweeps to heal (confirmation_threshold defaults to -1)
+```
+
+or scope it to this plugin only, by longest-prefix match on `source_id`, which is always
+`graph_watchdog`:
+
+```yaml
+# entity_thresholds.yaml, loaded via the fault_manager's entity_thresholds.config_file param
+graph_watchdog:
+ healing_enabled: true
+ healing_threshold: 1 # 2 consecutive clean sweeps to heal (confirmation_threshold defaults to -1)
+```
+
+Healing time does not depend on how long the fault was active. The debounce counter is
+clamped to `[confirmation_threshold, healing_threshold]` on every event, so a FAILED event
+can push it down to `confirmation_threshold` and no further; a long-running fault does not
+dig a hole that later clean sweeps have to climb out of. After the fix, each clean sweep
+steps the counter up by one, so healing takes at most
+`healing_threshold - confirmation_threshold` consecutive clean sweeps - exactly that many once
+the counter has walked down to the floor, fewer if the fault was raised only briefly.
+
## Where GRAPH_* faults hang
Every `GRAPH_*` fault is raised with `source_id: graph_watchdog`. That is an App the
@@ -339,6 +384,204 @@ local graph-cache queries, so every topic is checked every tick. `/rosout` and
`configure()`; both C++ tiers hand `configure()` a JSON object directly.
+#### `param_drift` (GRAPH_PARAM_DRIFT)
+
+Watches ROS 2 node parameters and raises `GRAPH_PARAM_DRIFT` when a live value diverges
+from its reference. It needs no configuration to be useful: it captures each parameter's
+value once the node arms and flags later runtime changes. Pinning a parameter to an
+absolute value with `expect` additionally catches the case self-capture cannot see, a value
+that was already wrong when the node started.
+
+**A changed parameter is not the same as a broken robot.** The detector reports that a
+value moved and does not grade the consequence. Whether the new value matters is a question
+about the machine, not about the graph, so it raises at WARN and leaves the judgement to
+whoever reads the fault.
+
+**Aggregated fault, not per-node.** All drift in the graph is one graph-level
+`GRAPH_PARAM_DRIFT`, not a fault per owning node, for the same reason as the other
+detectors: the fault_manager identifies a fault by `fault_code` alone, so per-node faults
+would collide into a single record. The description enumerates every currently-drifted
+`(node, parameter)` pair, up to a cap of 480 characters - past that the text is truncated. Each
+app's own contribution is trimmed to 150 characters before that, so one node drifting on many
+parameters cannot fill the 480 by itself, and the entries are ordered `expect` violations first,
+then self-captured drift, with newly detected apps ahead of the ones already reported inside each
+group, so the cap cannot hide the two things worth reading. It clears
+(`EVENT_PASSED`) on every sweep where nothing drifts, so one node reverting is not enough while
+another still drifts, subject to the same `healing_enabled` requirement described in "Closing the
+loop" above. A clear is withheld while any app still has no usable read - one the sweep has not
+reached, one whose reads keep failing, or one the reliability gate has not armed - because health
+that was never measured is not reported as health. That withholding is bounded in both directions:
+an app whose reads keep failing stops holding the clear back once it has been given up on (see
+below), and a gate-denied app stops once the frozen hold has elapsed. An app whose reads keep
+failing is named in the log once ten consecutive attempts have failed; one the sweep has not yet
+attempted is not named, because nothing is known about it yet - instead, a clear that has been
+withheld for a whole minute of ticks is logged once, saying how many apps are behind it and naming
+one, so an operator watching a fault refuse to heal can tell a graph the sweep cannot cover from a
+detector that is simply working.
+
+```yaml
+plugins:
+ graph_watchdog:
+ detectors:
+ param_drift:
+ expect:
+ use_sim_time: false # flag any node running on sim time in production
+ ignore: ["*_stamp"] # only filters self-captured params, so keep baseline enabled
+```
+
+`baseline` and `expect` combine: an empty `expect` with `baseline: true` is pure
+self-capture; a sparse `expect` with `baseline: true` is pins plus self-capture;
+`baseline: false` checks only the pinned parameters and moves far less data, because it never
+reads the full parameter list. It is not cheaper against the budget, though: a self-capture visit
+is charged 2 round trips no matter how many parameters the node has, while a single pin the node
+declares is charged 3.
+
+**Coverage latency.** The sweep is budgeted by service ROUND TRIPS at the watched nodes - not by
+node count, and not by transport calls either. `max_reads_per_tick` bounds how many round trips
+happen per tick period, and the reader paces itself to that rate rather than reading back to back.
+A node's whole read set is read in one visit, and the visit is then charged in round trips rather
+than in calls. What it is charged is not always what the node paid:
+
+| Visit | Round trips charged | What the node actually pays |
+|-------|---------------------|-----------------------------|
+| `baseline: true` | 2 | 2 - one list, one batched get, whatever the parameter count. 4 on the very first contact; see below. |
+| one `expect` pin the node declares | 3 | 3 - a name-scoped list, a get, and a descriptor read the transport makes unconditionally on the success path |
+| one `expect` pin the node does not declare | 2 | 1 - the name-scoped list comes back empty and the transport stops there. The charge is the higher of the two NOT_FOUND paths: a name the list DOES find and the get then returns no value for costs 2, and the error code cannot tell them apart, so the bound overstates rather than understates. |
+| one `expect` pin the node declares but has not set | 3 | 3 - not a NOT_FOUND at all. rclcpp answers an unset parameter with a `PARAMETER_NOT_SET` value rather than an absent one, so the read succeeds, pays for the descriptor like any other resolved pin, and the pin fires: a node holding no value at all does not satisfy a pin that names one. It is reported as `got=`, not as `got=null`, because a NaN-valued parameter reads as `null` too and the two are different faults. |
+
+The right-hand column is measured rather than asserted: `test/e2e/test_param_roundtrip_e2e.test.py`
+drives the real transport against a node that answers the parameter services by hand and counts
+every request it is asked to serve.
+
+Visiting every node once therefore takes at least about
+
+- `ceil(2 * node_count / max_reads_per_tick)` tick periods with `baseline: true`, and
+- with `baseline: false`, `ceil(3 * pin_count * node_count / max_reads_per_tick)` where every node
+ declares every pin, down to `ceil(2 * pin_count * node_count / max_reads_per_tick)` where none of
+ them does,
+
+and a drift on a node not yet visited is invisible until then. At the defaults (budget 8, 1 s
+tick) a 20-node graph is about 5 ticks in self-capture mode; the same graph with four pins every
+node declares is about 30, and about 20 if no node declares any of them. Treat that as a floor
+rather than a bound: a read that overruns its slot is not compensated, so a few slow nodes stretch
+the rotation further.
+
+In `baseline: true` mode the very FIRST read of a given node costs two round trips more than the
+table charges, because `list_parameters` primes the transport's defaults cache before doing its own
+read. That excess is deliberately not charged: the cache has no expiry, so the pair is paid once
+per node for the life of the gateway, and charging it on every read would throttle the whole run to
+cover one rotation. The practical effect is that the first rotation after startup puts up to twice
+the budgeted load on the nodes in it, and every rotation after that stays within budget.
+`baseline: false` has no cold surcharge at all: `get_parameter` never primes that cache, so the
+first pinned read of a node costs the same three round trips as every later one.
+
+Coverage latency governs repairs as well as detections: a node that reverted but has not been read
+again yet keeps its stale entry, so the aggregated fault can stay raised for up to one rotation
+after the underlying fix. Raise `max_reads_per_tick`, or lower the tick interval, to shorten that
+window on a big graph.
+
+There is one stale entry those two knobs do NOT shorten. A node that is present but has left the
+reliability gate's `active` state is not read at all, so its finding is frozen rather than
+re-derived - which is right for a pause, and wrong for good. `kFrozenHoldTicks`, a compile-time 60
+consecutive gate-denied ticks (a minute at the default tick period), bounds it: past that the
+finding is released and the app also stops holding back a clear, and both come back from a fresh
+read if it ever arms again. Because that horizon is counted in TICKS and not in rotations, raising
+`max_reads_per_tick` does nothing to it and shortening `tick_interval_ms` shortens it only
+incidentally, by making a tick shorter.
+
+What is NOT derived from the rotation is anything the detector says about an app that will not
+answer. Both the log line and the point at which it stops holding back a clear rest primarily on
+completed, FAILED read attempts and not on elapsed ticks, so an app that is merely waiting its turn
+is never announced as silent: the log names an app once ten consecutive reads of it have failed,
+and the detector stops letting it hold back a clear only once more than sixty have. Each of those
+carries an additional FLOOR in ticks, of the same size, which the app must also have spent
+unread - an attempt is not a duration, and without the floor `max_reads_per_tick: 100000` spends
+sixty-one attempts in a couple of milliseconds and writes off a node that has merely not finished
+being discovered. The floor is a conjunction, so it can only ever delay the two, never bring them
+forward. An armed app the sweep has never ATTEMPTED blocks the clear for as long as that stays
+true, however long the rotation takes; a cached read is not a substitute, so an app whose reads
+start failing blocks the clear again from the first failure.
+
+The reads run on a thread the plugin owns, never on the gateway's ROS executor, so a node
+that stops answering stalls only this detector's sweep and not the gateway's request
+handling.
+
+**Getting back to clean after a deliberate change.** The detector cannot tell an
+intentional runtime change from a misconfiguration; it only knows the value moved away from
+what it last saw. Reverting the parameter always works: the aggregate clears on the first sweep
+where nothing drifts and every app has either been read, been given up on after repeated failed
+reads, or exhausted the frozen hold with the gate still shut.
+
+Restarting the node only works if the restart is slow enough to be SEEN. A captured baseline
+is dropped on the third consecutive sweep its app is absent from (sooner only if
+`prune_grace` is set below 2, see its row above), and only then does the node's return start from
+a fresh capture. That window is deliberate. DDS discovery drops a
+node from a single poll routinely, with no restart involved, so a one-sweep window would
+re-baseline that node on its already-drifted value and heal a true positive that could then
+never fire again, because the new baseline IS the wrong value. The cost runs the other way: a
+node that comes back inside the window keeps its pre-restart baseline and goes on being
+reported as drifted until someone reverts the change. `respawn` in a launch file defaults to
+no delay, so the usual edit-the-YAML-and-restart flow is frequently never observed as an
+absence at all: a respawn that completes inside one tick leaves the node present on every
+sweep, and no window, however short, catches that. To make a restart re-baseline, keep the
+node out of the graph long enough to miss three sweeps: a `respawn_delay`, or a stop, a pause,
+then a start. Accepting a new value as the baseline without a restart does not exist today.
+
+**Test tiers:**
+
+1. **Unit** (`test/test_param_drift_policy.cpp`): the pure comparison, rendering and
+ configuration rules against hand-built values - the glob matching, the exact
+ integer-versus-float comparison and the NaN carve-out, the `baseline` and `expect`
+ combinations, the config range checks, the ASCII-only elided rendering the capped description
+ depends on, and the `read_gap` spacing the pacing budget is built on.
+2. **Integration** (`test/test_param_drift_integration.cpp`): real `rclcpp` nodes with real
+ parameters, read over the real parameter service against a fake `ReportFault` service, plus a
+ scripted stand-in transport for the replies no live node can be made to produce on demand - a
+ read still in flight when its app is de-armed, a SUCCESS carrying no parameters at all, a read
+ that throws, a node that never answers. It covers the grace-based prune when a node vanishes,
+ the re-baseline after an absence long enough to be a restart together with the single dropped
+ poll that must NOT re-baseline, that the baseline forget is reachable at every accepted
+ `prune_grace`, that the baseline horizon is exactly three missed sweeps and not two or four,
+ the clear path, that a closing reliability gate does not heal a present drift, and every state
+ in which a clear must be withheld because nothing is known about an app: one the sweep has not
+ reached, one the gate has not armed, and one that answered once and then stopped. It also
+ covers that both unread warnings need FAILED attempts AND elapsed ticks rather than either
+ alone, that an app given up on stops blocking the clear, that a withheld clear says so in the
+ log once, that a read overrunning the per-read bound is abandoned, that an app re-bound to a
+ different node starts from a fresh baseline, the per-app share of the description and that
+ trimming it keeps both ends, and the ordering that decides what survives the capped
+ description: a newly drifted app ahead of the ones already reported, and an `expect` violation
+ ahead of self-captured drift - including the cross case, where a pinned violation already
+ reported still outranks brand-new self-captured drift. The pacing budget is covered in both
+ tiers - `read_gap`'s arithmetic in the unit tests above, and the rate the reader actually
+ achieves against a configured budget here.
+3. **E2e** (`test/e2e/test_param_drift_e2e.test.py`,
+ `test/e2e/test_param_roundtrip_e2e.test.py`,
+ `test/e2e/test_config_plumbing_e2e.test.py`): five real gateway launches.
+ The first is the acceptance gate: it drives the detector's DEFAULT self-capturing mode end to
+ end - a real node whose parameter the test changes over the real parameter service, a real
+ fault_manager, and both the raise and the heal read back from the operator-visible
+ `GET /api/v1/faults`.
+ The second is what makes the round-trip table above a measurement rather than an assertion. It
+ runs with `param_drift` switched OFF and drives the same two transport functions the detector
+ uses over `GET /{entity}/configurations`, against a node that answers the parameter services by
+ hand and counts every request it is asked to serve: a cold and a warm `baseline` visit, a
+ resolved pin, both NOT_FOUND paths (the unlisted name that costs 1 and the listed one with no
+ value that costs 2, which is what the charge of 2 is sized for), a declared-but-unset pin that
+ is a success costing 3, and a first pinned read of an untouched node. Nothing else in the
+ package can notice a round trip being added to or removed from the TRANSPORT - every other test
+ proves the arithmetic GIVEN those numbers, and the stand-in transport they use counts CALLS.
+ It does not read the detector's charge constants either, though: those are held to the
+ measurement by the timing cases in the integration suite, which pace the reader off them.
+ The last three are the only proof that a nested SUB-OBJECT under a detector key survives the
+ delivery path: `expect.` arrives from the gateway as nested JSON and has to be flattened
+ back to the dotted parameter name. (Plain per-detector scalars and arrays are proven by the
+ orphan e2e.) Every C++ test above calls `configure()` directly with hand-built JSON and never
+ touches the delivery path. One launch proves the nested
+ `expect` reader, one proves `mode: "off"`, and one proves the bare `mode: off` that the
+ ROS parser types as a YAML 1.1 boolean rather than a string, which is the form operators
+ actually write.
+
## Reliability (bringup-quiesce)
Silent-fault detectors are prone to bringup noise: a node joining the graph, a
@@ -413,7 +656,7 @@ itself. The two things a detector opts into explicitly are
`/tf_static`, whose publishers latch so a late subscriber must match the
durability to receive them).
-## Adding a detector
+## Adding a detector
1. Create `src/detectors/.cpp`.
2. Subclass `Detector`; implement `id()` and `tick(DetectorContext&)`. Read the
@@ -434,4 +677,3 @@ Frozen in `include/ros2_medkit_graph_watchdog/graph_fault_codes.hpp`:
`GRAPH_QOS_MISMATCH`, `GRAPH_ORPHAN`, `GRAPH_NODE_DISAPPEARED`, `GRAPH_TF_STALE`,
`GRAPH_PARAM_DRIFT`, `GRAPH_LATENCY_BUDGET`, plus one extension of the frozen
namespace for a new capability beyond the original six: `GRAPH_NODE_INACTIVE`.
-
diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/design/graph_watchdog.rst b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/design/graph_watchdog.rst
index 27dd162b..2aed0cf0 100644
--- a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/design/graph_watchdog.rst
+++ b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/design/graph_watchdog.rst
@@ -89,8 +89,9 @@ Reliability core
Detectors
---------
-``qos_mismatch`` and ``orphan`` are the detectors this package ships so far. The
-remaining silent-fault classes land in follow-up changes, each against its own issue.
+``qos_mismatch``, ``orphan`` and ``param_drift`` are the detectors this package ships so
+far. The remaining silent-fault classes land in follow-up changes, each against its own
+issue.
``qos_mismatch`` raises ``GRAPH_QOS_MISMATCH``. It
watches every topic's publisher/subscriber QoS pairs rather than parameter values: each
@@ -129,11 +130,10 @@ are not checked. The checks match only concrete incompatible enum pairs, so
``SYSTEM_DEFAULT``/``UNKNOWN`` (never
reported by a live endpoint, which always carries the resolved profile) never raise.
-**Aggregation via the shared helper.** ``qos_mismatch`` is the first detector to use
-the new ``AggregatedFault`` helper (``aggregated_fault.hpp``), factored out of
-a shared helper so later detectors do not each reimplement the same
-level-triggered raise/clear pattern. The rationale is
-(see "Aggregation rationale" above): the fault_manager identifies a fault by
+**Aggregation via the shared helper.** ``qos_mismatch`` was the first detector to use the
+``AggregatedFault`` helper (``aggregated_fault.hpp``); ``orphan`` and ``param_drift`` go
+through the same one, so no detector reimplements the level-triggered raise/clear
+pattern. The rationale: the fault_manager identifies a fault by
``fault_code`` alone, so one ``GRAPH_QOS_MISMATCH`` per mismatched topic would collide
into a single record under the shared code. The detector keeps one ``AggregatedFault``
instance for the whole graph and, each tick, hands it every currently-mismatched
@@ -141,12 +141,17 @@ topic's description (keyed by topic name so a repeat mismatch on the same topic
overwrites rather than duplicates); an empty map on a clean tick clears
(``EVENT_PASSED``), level-triggered semantics (see
"Closing the loop" in the README for the ``healing_enabled`` requirement to actually
-reach HEALED). ``graph_source_id()``, also in ``aggregated_fault.hpp``, is the same
-host-Component-id-or-literal-fallback logic - the detectors'
-aggregated faults land under the same ``source_id``.
+reach HEALED). ``graph_source_id()``, also in ``aggregated_fault.hpp``, returns the
+constant ``kGraphWatchdogEntityId`` (``"graph_watchdog"``) and nothing else, so every
+detector's aggregated faults land under the same ``source_id``. It deliberately does NOT
+fall back to the host Component id: a runtime host Component built by ``HostInfoProvider``
+never sets ``external``, ``collect_component_app_fqns`` only puts a Component's bare id in
+the fault scope set when it does, and a fault raised under it is therefore listed by no
+entity endpoint at all. The ``ctx`` argument is unused and kept only because every call
+site already has it in hand.
-**Coverage is exhaustive, not budgeted.** Unlike a call-budgeted
-round-robin sweep (bounded by a parameter-service transport call budget),
+**Coverage is exhaustive, not budgeted.** Unlike a budgeted
+round-robin sweep (bounded by a parameter-service round-trip budget),
``qos_mismatch`` reads no external service - ``get_topic_names_and_types()`` and the
two ``get_*_info_by_topic()`` calls are local graph-cache queries, so every topic is
checked every tick with no coverage-latency trade-off to configure or budget knob to
@@ -221,9 +226,99 @@ so it also proves a string array and an integer survive the parameter path into
``configure()``, which no C++ tier can show.
+``param_drift`` raises ``GRAPH_PARAM_DRIFT``. It reads node parameters over the
+parameter service and reports a value that no longer matches its reference. The
+reference is either self-captured, the value the parameter had when the node armed, or
+pinned in config through ``expect``. A node absent for longer than the window the
+reliability gate absorbs loses its captured baseline, so a restart - the documented way to apply
+a new configuration - re-captures instead of reporting the change as drift. A shorter gap keeps
+the baseline on purpose: DDS discovery drops a node from a single poll routinely, and re-capturing
+on that would take the node's current, possibly already drifted, value as the new reference and
+heal a real fault that could then never fire again. Only the pinned form can catch a value that was
+already wrong at startup; self-capture by construction treats whatever it first sees as
+correct.
+
+**A budgeted sweep, unlike every other detector here.** ``qos_mismatch`` and ``orphan``
+read the local graph cache and are therefore exhaustive every tick. This one makes real
+service calls to other nodes, so it is bounded by ``max_reads_per_tick`` and sweeps the
+graph round-robin. The budget is spent in service ROUND TRIPS at the watched node rather
+than in transport calls, because one call is several requests there: a self-capture read
+is a list plus a batched get, and an ``expect`` pin the node declares is a list, a get and a
+descriptor read. A pin the node does NOT declare stops at the list, and is charged two rather
+than the one it costs, because a second and rarer NOT_FOUND path does cost two and the error
+code cannot tell them apart - a load bound may overstate, never understate. That buys a bound
+on the load the sweep puts on watched nodes and pays with
+coverage latency: a drift is invisible until its node's turn comes, and so is a repair.
+The reads run on a thread the plugin owns rather than the gateway executor, so a node
+that stops answering stalls this sweep only.
+
+**Three horizons for a stale entry.** A node missing from one sweep is not forgotten
+immediately. Its captured BASELINE is dropped on the third consecutive sweep without it - the
+two the warmup tracker's forget grace absorbs, plus one - so that a one-tick discovery gap does
+not silently reset the baseline and hide a real drift behind a fresh capture. Its FINDING
+survives ``prune_grace`` consecutive absences and is dropped on the next one, so that the same
+gap does not clear the aggregated fault either. Only the finding horizon is configurable:
+``prune_grace`` is a per-detector config key, while the baseline horizon is the compile-time
+``kBaselineForgetGrace``. A ``prune_grace`` below 2 makes the finding the shorter of the two and
+the baseline then goes on the same sweep, because the absence counter it rides on is dropped
+with the finding.
+
+The third horizon is not about absence at all. A node that stays PRESENT but leaves the
+reliability gate's ``active`` state is never re-evaluated (it is not armed) and never pruned (it
+is still there), so its finding would otherwise be re-raised for ever - an operator who fixes
+the parameter and leaves the node deactivated would watch ``GRAPH_PARAM_DRIFT`` stay CONFIRMED
+naming a value that is no longer true. ``kFrozenHoldTicks`` (60 consecutive gate-denied ticks,
+one minute at the default tick period) bounds that: past it the finding is released and the app
+also stops holding back a clear, and both re-derive from a fresh read if it ever arms again.
+Because it is a compile-time constant and counted in ticks rather than in rotations, neither
+``max_reads_per_tick`` nor ``tick_interval_ms`` shortens it.
+
+**Severity is WARN, deliberately.** The detector knows a value moved; it does not know
+whether that breaks the robot. Grading the consequence needs knowledge of the machine
+that the graph does not carry.
+
+**Nothing is cleared that was not measured.** A clear asserts that nothing is drifting anywhere,
+which is only true if every app was actually looked at, so the aggregate emits nothing at all while
+any app still has no usable read. That covers three states which are all "nothing is known about
+this app": the round-robin has not reached it, its reads keep failing, or the reliability gate has
+not armed it - and the last of those is the ordinary state of every app for the first
+``warmup_cycles`` ticks of a run, so without it a restart clears before issuing a single round trip.
+A cached read counts as evidence only until a read of that app FAILS, after which the app is
+unmeasured again. Each hold is bounded from the other side: an app is given up on after both sixty
+failed attempts and sixty ticks unread, and a gate-denied one after the frozen hold. The bound on
+attempts alone would not do, because an attempt is not a duration - the reader paces itself off
+``max_reads_per_tick``, so at the accepted ceiling sixty-one attempts are spent in milliseconds and
+a node still being discovered would be written off. Finally, a clear withheld for a minute of ticks
+is logged once with the count of apps behind it, because from outside the process a correctly
+withheld clear and a detector that has nothing to report look exactly the same.
+
+**Test tiers.** ``test_param_drift_policy.cpp`` pins the pure comparison, rendering and
+config rules. ``test_param_drift_integration.cpp`` drives real nodes with real parameters over
+the real parameter service against a fake ``ReportFault`` service, including a node that
+never answers, which must not hang the tick, and a node that answers past the per-read bound,
+which must not be read at all.
+``test/e2e/test_param_drift_e2e.test.py`` is the acceptance gate: one gateway launch carrying
+the whole raise-and-clear story for the detector's default self-capturing mode, with a real
+fault_manager and both ends read back from ``GET /api/v1/faults``.
+``test/e2e/test_param_roundtrip_e2e.test.py`` measures what one parameter read really costs the
+node that serves it, against a node that answers the parameter services by hand and counts every
+request - so a round trip added to or removed from the TRANSPORT is caught, instead of quietly
+invalidating the round-trip figures the budget is built on. Nothing else in the package can
+notice that: every other test proves the arithmetic GIVEN those figures, and the stand-in
+transport they use counts calls. It does not, however, read the detector's own charge constants
+at all - it runs with ``param_drift`` switched off - so what holds those to the measurement is
+the timing suite in ``test_param_drift_integration.cpp``, which times the reader against a
+configured budget and therefore moves when a charge does. Cost and charge are pinned separately
+and on purpose.
+``test/e2e/test_config_plumbing_e2e.test.py`` drives three further gateway launches covering the
+nested ``expect`` sub-object reader, ``mode: "off"``, and the bare ``mode: off`` that the
+ROS parser types as a YAML 1.1 boolean; those three prove configuration delivery and
+suppression only, and assert no clear.
+
+
Status
---------------
The plugin loads, ticks the graph, and shuts down cleanly. The reliability core is real
-and already ticking. Two silent-fault detector classes raise through it today,
-``qos_mismatch`` and ``orphan``. The remaining classes land in follow-up changes, each
-against its own issue.
+and already ticking. Three silent-fault detector classes raise through it today,
+``qos_mismatch``, ``orphan`` and ``param_drift``. The remaining classes land in follow-up
+changes, each against its own issue.
diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/include/ros2_medkit_graph_watchdog/aggregated_fault.hpp b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/include/ros2_medkit_graph_watchdog/aggregated_fault.hpp
index 3e2e088f..7fda64c0 100644
--- a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/include/ros2_medkit_graph_watchdog/aggregated_fault.hpp
+++ b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/include/ros2_medkit_graph_watchdog/aggregated_fault.hpp
@@ -63,9 +63,10 @@ class AggregatedFault {
public:
/// Cap on the generated description. One aggregated fault names every affected entity, so
/// on a large graph the raw text grows without bound - an oversized ReportFault description
- /// then travels into every /faults payload and the fault_manager's store. param_drift's
- /// hand-rolled aggregation applies the same cap; this keeps every detector that uses the
- /// helper consistent with it.
+ /// then travels into every /faults payload and the fault_manager's store. Every detector
+ /// aggregates through this helper, so the cap is applied in one place rather than copied per
+ /// detector. A detector may additionally trim what a SINGLE entity contributes before handing
+ /// it over (param_drift does), so that one entity cannot spend the whole budget by itself.
static constexpr std::size_t kMaxDescriptionChars = 480;
AggregatedFault(const char * code, std::uint8_t severity) : code_(code), severity_(severity) {
diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/include/ros2_medkit_graph_watchdog/detector.hpp b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/include/ros2_medkit_graph_watchdog/detector.hpp
index c56062d6..2db5b83d 100644
--- a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/include/ros2_medkit_graph_watchdog/detector.hpp
+++ b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/include/ros2_medkit_graph_watchdog/detector.hpp
@@ -16,6 +16,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -28,7 +29,12 @@
namespace ros2_medkit_gateway {
struct IntrospectionInput;
-}
+// Only named in the test-only transport factory signature below, so a declaration is enough.
+// Including the gateway header here would pull it into every consumer of this one, and the test
+// targets that do not add GATEWAY_SRC_INCLUDE_DIR would then resolve it from install/ while the
+// plugin resolves it from the source tree - the split this package's own CMakeLists warns about.
+class ParameterTransport;
+} // namespace ros2_medkit_gateway
namespace ros2_medkit_graph_watchdog {
@@ -128,6 +134,21 @@ class Detector {
virtual std::size_t tracked_count_for_test() const {
return 0;
}
+
+ /// Test-only injection hook for detectors that read parameters over a transport. Returns false
+ /// when the detector does not use one, so a test can assert it reached the right detector rather
+ /// than silently doing nothing.
+ ///
+ /// It lives on the base because detectors are file-local and self-registering: a test only ever
+ /// holds a `Detector*` from the registry, so there is no derived type to cast to. The alternative
+ /// is leaving the behaviours that only a stand-in transport can produce - a read still in flight
+ /// when its app is de-armed, a SUCCESS response carrying no parameters, a transport that fails to
+ /// construct - untested, and none of them is reachable from a live ROS graph.
+ using ParameterTransportFactory =
+ std::function(rclcpp::Node *)>;
+ virtual bool set_parameter_transport_factory_for_test(const ParameterTransportFactory & /*factory*/) {
+ return false;
+ }
};
} // namespace ros2_medkit_graph_watchdog
diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/include/ros2_medkit_graph_watchdog/param_drift_policy.hpp b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/include/ros2_medkit_graph_watchdog/param_drift_policy.hpp
new file mode 100644
index 00000000..f026e3c2
--- /dev/null
+++ b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/include/ros2_medkit_graph_watchdog/param_drift_policy.hpp
@@ -0,0 +1,475 @@
+// Copyright 2026 bburda
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include