From a6df0040ce0f67e15075e81df5eabb4d90a34e9b Mon Sep 17 00:00:00 2001 From: vdmo Date: Thu, 1 Oct 2026 00:31:19 +1000 Subject: [PATCH] =?UTF-8?q?fix(conductor):=20exclude=20Heartbeat=20from=20?= =?UTF-8?q?rule=20evaluation=20=E2=80=94=20was=20re-signing=20unchanged=20?= =?UTF-8?q?violations=20forever?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found live while testing the TiXL bridge (built on top of this branch's WS-broadcast fix): process_message unconditionally re-evaluated all rules after every message, including Heartbeat, which carries no scene- state change. A real, still-unaddressed violation (tentacle_system. total_curl left over threshold) got re-signed into a brand-new receipt on every subsequent heartbeat — every 5s by default in the bundled bridges — for as long as the condition sat unaddressed. Reproduced: one real DENY produced 5 identical signed receipts (same receipt_id, since nothing about the content changed) over 20 idle seconds. Not fixed by de-duplicating receipts after the fact — fixed at the source: Heartbeat now returns early from process_message before equilibrium-status update or rule evaluation runs, since a message that changes nothing has nothing new to evaluate. Added a regression test: a real violation produces exactly one receipt, then 5 heartbeats against the same unchanged state must not add any more. 31/31 tests pass; clippy clean. Verified live, not just in the test suite: re-ran the exact scenario that surfaced this (real conductor, real TiXL-bridge heartbeat traffic, a real lingering DENY) for 22 seconds spanning 4 heartbeat cycles — 1 receipt, not 5. Co-Authored-By: Claude Sonnet 5 --- primeswarm-pchi/src/conductor.rs | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/primeswarm-pchi/src/conductor.rs b/primeswarm-pchi/src/conductor.rs index d68c41e..e7c1e62 100644 --- a/primeswarm-pchi/src/conductor.rs +++ b/primeswarm-pchi/src/conductor.rs @@ -150,6 +150,23 @@ impl PCHIConductor { } } + // Heartbeats carry no scene-state change — they exist purely so a + // bridge's liveness is visible, and set nothing in `state`. Before + // this, every message unconditionally re-evaluated all rules + // against whatever `state` already held, so a real, still-unfixed + // violation from an earlier message (e.g. a curl value left over + // threshold) got re-signed into a brand-new receipt on every + // subsequent heartbeat — every 5s by default in the bundled + // bridges — forever, for as long as that condition sat unaddressed. + // Found live: one real DENY produced 5 identical signed receipts + // (same receipt_id, since nothing about the content had changed) + // over 20 seconds of an otherwise-idle bridge. Not fixed by + // de-duplicating receipts after the fact — fixed at the source: + // a message that changes nothing has nothing new to evaluate. + if matches!(message.message_type, MessageType::Heartbeat) { + return Ok(()); + } + // Update equilibrium status state.update_equilibrium_status(self.equilibrium_threshold); @@ -387,4 +404,45 @@ mod tests { ws_task.abort(); let _ = std::fs::remove_dir_all(&dir); } + + #[tokio::test] + async fn heartbeat_does_not_re_sign_a_lingering_violation() { + let (conductor, dir) = test_conductor().await; + + // Leave a real, still-unfixed violation in scene state. + let violating = PCHIMessage::new( + MessageType::ControlParameter, + "test".to_string(), + Payload::ControlParameter(ControlParameterData { + target_id: "tentacle_system".to_string(), + parameter: "total_curl".to_string(), + value: serde_json::json!(10.0), + }), + ); + assert!(conductor.process_message(violating).await.is_err()); + let count_after_violation = conductor.get_governance_arc().export()["count"].as_u64().unwrap(); + assert_eq!(count_after_violation, 1, "the real violation should produce exactly one receipt"); + + // Five heartbeats, same unaddressed condition still sitting in state. + for _ in 0..5 { + let heartbeat = PCHIMessage::new( + MessageType::Heartbeat, + "test_bridge".to_string(), + Payload::ControlParameter(ControlParameterData { + target_id: "test_bridge".to_string(), + parameter: "heartbeat".to_string(), + value: serde_json::json!(1.0), + }), + ); + assert!(conductor.process_message(heartbeat).await.is_ok()); + } + + let count_after_heartbeats = conductor.get_governance_arc().export()["count"].as_u64().unwrap(); + assert_eq!( + count_after_heartbeats, count_after_violation, + "heartbeats must not produce new receipts for a condition that hasn't changed" + ); + + let _ = std::fs::remove_dir_all(&dir); + } }