mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 15:01:10 +00:00
[AZ-680] [AZ-681] operator_bridge command dispatch + safety lane
Add the operator-command dispatcher behind a typed CommandAck: 60 s per-command-id idempotency cache, surfaced-POI registry with unknown_poi_id + expired gates, BIT-degraded ack severity check, and SafetyOverride forwarding to mission_executor with structured audit log (redacts signature + session_token). Cross-layer wiring goes through three new traits in shared::contracts (ScanCommandRouter, MissionSafetyRouter, BitReportSeverityLookup) so operator_bridge stays free of direct scan_controller / mission_executor imports. scan_controller::ScanControllerHandle implements the scan router; a new mission_executor::SafetyDispatchHandle wraps the BIT ack channel + battery monitor handle and implements the safety router; BitControllerHandle gains a bounded (16-entry) report-severity cache for the lookup trait. scan_controller also picks up ConfirmPoi handling: PoiQueue::confirm removes the entry and SubmitOutcome::Confirmed carries the typed (target_mgrs, target_class) hint for AZ-684/AZ-686 downstream. Tests: 9 new integration tests in operator_bridge/tests/dispatcher.rs cover AZ-680 AC-1..AC-5 + AZ-681 AC-1..AC-4. scan_controller adds 2 ConfirmPoi tests. All modified-crate suites green; one pre-existing mission_executor state-machine test flake (already documented in _docs/_process_leftovers) updated to note ac1 also affected. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
//! 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<Utc>,
|
||||
operator_id: Option<String>,
|
||||
report_id: Uuid,
|
||||
outcome: CommandAck,
|
||||
},
|
||||
SafetyOverride {
|
||||
command_id: Uuid,
|
||||
timestamp: DateTime<Utc>,
|
||||
operator_id: Option<String>,
|
||||
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<dyn AuditSink> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user