//! `operator_bridge` — POI surfacing + operator command authentication. //! //! Real implementation in this batch: //! - **AZ-678** `internal::auth::HmacOperatorValidator` — HMAC-SHA256 //! over `(session_token, sequence_number, payload)`; per-session //! replay tracker; session registry with TTL; rejection-reason //! counters; sliding-window red-health gate. //! - **AZ-679** `internal::poi_surface::PoiSurfaceMapper` — wire-format //! POI events + `PoiDequeued` events pushed through `TelemetrySink`. //! //! Real implementation lands in: //! - AZ-680 `operator_bridge_command_dispatch` //! - AZ-681 `operator_bridge_safety_and_bit_ack` pub mod internal; use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; use shared::contracts::{OperatorCommandSink, TelemetrySink}; use shared::error::{AutopilotError, Result}; use shared::health::{ComponentHealth, HealthLevel}; use shared::models::mission::Coordinate; use shared::models::operator::OperatorCommand; use shared::models::operator_event::{DequeueReason, PhotoMetadata}; use shared::models::poi::Poi; pub use crate::internal::auth::{ AuthCounters, HmacOperatorValidator, HmacValidatorConfig, REJECTION_REASONS, }; pub use crate::internal::poi_surface::{PoiSurfaceMapper, PoiSurfaceMetrics}; const NAME: &str = "operator_bridge"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OperatorDecision { Confirmed, Declined, TimedOut, StartTargetFollow, ReleaseTargetFollow, } #[derive(Debug, Clone)] pub struct MiddleWaypointHint { pub mission_id: String, pub at: Coordinate, } #[derive(Debug, Clone)] pub enum TargetFollowEvent { Start { target_id: String }, Release, } pub struct OperatorBridge { middle_waypoint_tx: mpsc::Sender, target_follow_tx: mpsc::Sender, middle_waypoint_rx: Option>, target_follow_rx: Option>, /// AZ-679 — POI surface mapper. Optional so existing single-arg /// constructors (used by tests + early scaffolding) keep working; /// composition root wires the real `TelemetrySink` via /// `with_telemetry_sink`. poi_mapper: Option>, /// AZ-678 — operator command validator. Same optional-pattern as /// `poi_mapper` so legacy callers continue to compile until the /// composition root wires it in. validator: Option>, } impl OperatorBridge { pub fn new(channel_capacity: usize) -> Self { let (mw_tx, mw_rx) = mpsc::channel(channel_capacity); let (tf_tx, tf_rx) = mpsc::channel(channel_capacity); Self { middle_waypoint_tx: mw_tx, target_follow_tx: tf_tx, middle_waypoint_rx: Some(mw_rx), target_follow_rx: Some(tf_rx), poi_mapper: None, validator: None, } } pub fn with_telemetry_sink(mut self, sink: Arc) -> Self { self.poi_mapper = Some(Arc::new(PoiSurfaceMapper::new(sink))); self } pub fn with_validator(mut self, validator: Arc) -> Self { self.validator = Some(validator); self } pub fn handle(&self) -> OperatorBridgeHandle { OperatorBridgeHandle { middle_waypoint_tx: self.middle_waypoint_tx.clone(), target_follow_tx: self.target_follow_tx.clone(), poi_mapper: self.poi_mapper.clone(), validator: self.validator.clone(), } } pub fn take_middle_waypoint_receiver(&mut self) -> Option> { self.middle_waypoint_rx.take() } pub fn take_target_follow_receiver(&mut self) -> Option> { self.target_follow_rx.take() } } #[derive(Clone)] pub struct OperatorBridgeHandle { #[allow(dead_code)] middle_waypoint_tx: mpsc::Sender, #[allow(dead_code)] target_follow_tx: mpsc::Sender, poi_mapper: Option>, validator: Option>, } impl OperatorBridgeHandle { /// AZ-679 — surface a POI to the operator and await the decision. /// Today returns `NotImplemented` (the decision loop is AZ-680); /// the surface event itself IS pushed (via the configured /// `TelemetrySink`), so the operator UI receives it. pub async fn surface_poi(&self, poi: Poi) -> Result { match &self.poi_mapper { Some(mapper) => { mapper.surface(&poi, None).await?; Err(AutopilotError::NotImplemented( "operator_bridge::surface_poi → decision loop (AZ-680)", )) } None => Err(AutopilotError::NotImplemented( "operator_bridge::surface_poi (no telemetry sink wired)", )), } } /// AZ-679 — surface a POI together with photo metadata (preferred /// path when the source detection carries an ROI snapshot). pub async fn surface_poi_with_photo( &self, poi: &Poi, photo_metadata: PhotoMetadata, ) -> Result<()> { let mapper = self.poi_mapper.as_ref().ok_or_else(|| { AutopilotError::Internal("surface_poi_with_photo: telemetry sink not wired".into()) })?; mapper.surface(poi, Some(photo_metadata)).await.map(|_| ()) } /// AZ-679 — emit a `PoiDequeued` event (rotation / age-out / /// completion). Called by `scan_controller` through the bridge. pub async fn emit_poi_dequeued(&self, poi_id: uuid::Uuid, reason: DequeueReason) -> Result<()> { let mapper = self.poi_mapper.as_ref().ok_or_else(|| { AutopilotError::Internal("emit_poi_dequeued: telemetry sink not wired".into()) })?; mapper.emit_dequeued(poi_id, reason).await } pub fn poi_metrics(&self) -> Option { self.poi_mapper.as_ref().map(|m| m.metrics()) } pub fn health(&self) -> ComponentHealth { let mut h = ComponentHealth::disabled(NAME); if self.poi_mapper.is_none() && self.validator.is_none() { return h; } // Once any sub-component is wired we surface green by default, // upgrade to red if the validator's signature-failure window // crosses the threshold (AC-5). h.level = HealthLevel::Green; if let Some(v) = &self.validator { if v.health_is_red() { h.level = HealthLevel::Red; } let c = v.counters(); h.detail = Some(format!( "validated_total={} sig_invalid={} replay={} session_unknown={} session_expired={}", c.validated_total(), c.reason(shared::contracts::operator_auth::AuthError::SignatureInvalid), c.reason(shared::contracts::operator_auth::AuthError::ReplayDetected), c.reason(shared::contracts::operator_auth::AuthError::SessionUnknown), c.reason(shared::contracts::operator_auth::AuthError::SessionExpired), )); } h } } #[async_trait] impl OperatorCommandSink for OperatorBridgeHandle { async fn dispatch(&self, _command: OperatorCommand) -> Result<()> { Err(AutopilotError::NotImplemented( "operator_bridge::dispatch (AZ-680)", )) } } #[cfg(test)] mod tests { use super::*; #[test] fn it_compiles_without_wiring() { let h = OperatorBridge::new(8).handle(); assert_eq!(h.health().level, shared::health::HealthLevel::Disabled); } #[test] fn health_green_once_validator_wired() { // Arrange let validator = Arc::new(HmacOperatorValidator::with_default_config()); // Act let bridge = OperatorBridge::new(8).with_validator(validator); let h = bridge.handle().health(); // Assert assert_eq!(h.level, shared::health::HealthLevel::Green); assert!(h.detail.unwrap().contains("validated_total=0")); } }