[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:
Oleksandr Bezdieniezhnykh
2026-05-20 17:32:38 +03:00
parent aa4282f9f8
commit c4eff40dbc
24 changed files with 2017 additions and 53 deletions
@@ -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"));
}
}