//! AZ-681 — structured audit log for safety-critical operator commands. //! //! Per the task spec (AC-4): every dispatched `BitDegradedAck` and //! `SafetyOverride` writes an audit entry containing: //! //! - command id //! - timestamp (UTC, ms precision) //! - operator id (when known) //! - scope / duration (for `SafetyOverride`) or `report_id` (for //! `BitDegradedAck`) //! - outcome (`Ok` / `Error { reason }`) //! //! Entries MUST NEVER contain the raw signature bytes or the session //! token (AC-4). Callers pass already-redacted fields; the writer //! has no access to the signature in the first place. //! //! ## Why both a sink trait + a tracing default //! //! - The default ([`TracingAuditSink`]) emits one structured //! `tracing::info!` per entry — meets the spec's "file or //! structured logger" requirement and integrates with whatever //! tracing subscriber the composition root wires. //! - The trait ([`AuditSink`]) lets tests substitute a recording //! sink without piggy-backing on tracing's global subscriber //! state (which other tests can race against). The integration //! tests in `tests/dispatcher.rs` use the recording sink. use std::sync::Arc; use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde::Serialize; use uuid::Uuid; use crate::ack::CommandAck; use shared::models::operator::SafetyOverrideScope; /// One entry in the audit log. Variants map 1:1 to the AZ-681 /// command kinds. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum AuditEntry { BitDegradedAck { command_id: Uuid, timestamp: DateTime, operator_id: Option, report_id: Uuid, outcome: CommandAck, }, SafetyOverride { command_id: Uuid, timestamp: DateTime, operator_id: Option, scope: SafetyOverrideScope, duration_secs: u32, outcome: CommandAck, }, } /// Sink for audit entries. Composition root injects the concrete /// implementation; the default is [`TracingAuditSink`]. #[async_trait] pub trait AuditSink: Send + Sync { async fn record(&self, entry: AuditEntry); } /// Default sink — emits a single `tracing::info!` per entry. The /// structured fields are picked up by any `tracing_subscriber` JSON /// layer the composition root configures. pub struct TracingAuditSink; impl TracingAuditSink { pub fn arc() -> Arc { Arc::new(Self) } } #[async_trait] impl AuditSink for TracingAuditSink { async fn record(&self, entry: AuditEntry) { match &entry { AuditEntry::BitDegradedAck { command_id, timestamp, operator_id, report_id, outcome, } => { tracing::info!( audit = "bit_degraded_ack", command_id = %command_id, timestamp = %timestamp.to_rfc3339(), operator_id = operator_id.as_deref().unwrap_or(""), report_id = %report_id, outcome = ?outcome, "operator_bridge audit: bit_degraded_ack" ); } AuditEntry::SafetyOverride { command_id, timestamp, operator_id, scope, duration_secs, outcome, } => { tracing::info!( audit = "safety_override", command_id = %command_id, timestamp = %timestamp.to_rfc3339(), operator_id = operator_id.as_deref().unwrap_or(""), scope = scope.label(), duration_secs = duration_secs, outcome = ?outcome, "operator_bridge audit: safety_override" ); } } } } #[cfg(test)] mod tests { use super::*; /// AC-4 sanity: an entry serialised to JSON contains no /// signature/session_token field. The entry struct itself has /// no such field, so this is a static guarantee — but we /// assert on the JSON shape to lock the wire contract. #[test] fn entry_json_has_no_signature_or_session_token() { // Arrange let entry = AuditEntry::SafetyOverride { command_id: Uuid::new_v4(), timestamp: Utc::now(), operator_id: Some("op-1".into()), scope: SafetyOverrideScope::BatteryRtl, duration_secs: 60, outcome: CommandAck::Ok, }; // Act let json = serde_json::to_string(&entry).expect("serialises"); // Assert assert!(!json.contains("signature")); assert!(!json.contains("session_token")); assert!(json.contains("battery_rtl")); assert!(json.contains("\"duration_secs\":60")); } }