mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 11:11:10 +00:00
c4eff40dbc
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>
152 lines
4.9 KiB
Rust
152 lines
4.9 KiB
Rust
//! 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"));
|
|
}
|
|
}
|