[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
@@ -33,16 +33,27 @@
//! subsequent `Degraded` / `Fail` flips it back to `false` and the
//! FSM's `bit_ok` guard fails closed.
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use shared::contracts::BitReportSeverityLookup;
use tokio::sync::{broadcast, mpsc, watch, Mutex};
use tokio::task::JoinHandle;
use tokio::time::Instant;
use uuid::Uuid;
/// AZ-681 — bounded FIFO cap for the per-report `BitOverall` cache
/// queried by [`BitControllerHandle::is_acknowledgeable`]. BIT is a
/// pre-flight gate that goes sticky-Pass after success, so the
/// number of distinct report ids generated in one flight is small
/// (one per evaluation cycle until Pass / Failed). 16 is generous
/// without unbounded growth.
const REPORT_OVERALL_CAP: usize = 16;
// ============================================================================
// Public surface — types
// ============================================================================
@@ -236,6 +247,7 @@ impl BitController {
state: BitState::Idle,
last_report: None,
sticky_pass: false,
report_overalls: VecDeque::with_capacity(REPORT_OVERALL_CAP),
}));
let handle = BitControllerHandle {
@@ -335,6 +347,11 @@ impl BitController {
config.ack_timeout,
);
let report_clone = report.clone();
record_report_overall(
&mut guard.report_overalls,
report.id,
report.overall,
);
guard.last_report = Some(report);
if new_state != from {
guard.state = new_state.clone();
@@ -442,6 +459,28 @@ struct ControllerInner {
/// downstream surfaces (lost-link ladder, geofence, battery —
/// AZ-651 / AZ-652).
sticky_pass: bool,
/// AZ-681 — recent `(report_id, overall)` pairs for the
/// `BitReportSeverityLookup` impl. Bounded FIFO; oldest evicted
/// at [`REPORT_OVERALL_CAP`]. A `None` lookup result means the
/// id has either never been generated or has aged out.
report_overalls: VecDeque<(Uuid, BitOverall)>,
}
/// Push a `(report_id, overall)` pair onto the bounded FIFO cache.
/// Re-recording an existing id is a no-op (preserves the original
/// position so callers can't accidentally refresh aging).
fn record_report_overall(
cache: &mut VecDeque<(Uuid, BitOverall)>,
report_id: Uuid,
overall: BitOverall,
) {
if cache.iter().any(|(id, _)| *id == report_id) {
return;
}
if cache.len() == REPORT_OVERALL_CAP {
cache.pop_front();
}
cache.push_back((report_id, overall));
}
/// Read-side handle for the BIT controller. Cloneable.
@@ -475,6 +514,32 @@ impl BitControllerHandle {
pub async fn last_report(&self) -> Option<BitReport> {
self.inner.lock().await.last_report.clone()
}
/// AZ-681 — overall verdict for a previously-generated report.
/// Returns `None` if the id has never been generated or has aged
/// out of the bounded cache.
pub async fn report_overall(&self, report_id: Uuid) -> Option<BitOverall> {
self.inner
.lock()
.await
.report_overalls
.iter()
.find_map(|(id, o)| (*id == report_id).then_some(*o))
}
}
/// AZ-681 — `operator_bridge` (Layer 3) consults this before
/// forwarding a BIT-degraded ack. `Fail` reports are never
/// acknowledgeable (per AZ-681 AC-2). An aged-out / never-seen id
/// returns `None` so the bridge can NACK with a typed
/// "unknown report id" reason.
#[async_trait]
impl BitReportSeverityLookup for BitControllerHandle {
async fn is_acknowledgeable(&self, report_id: Uuid) -> Option<bool> {
self.report_overall(report_id)
.await
.map(|o| !matches!(o, BitOverall::Fail))
}
}
#[cfg(test)]
@@ -11,5 +11,6 @@ pub mod lost_link;
pub mod middle_waypoint;
pub mod multirotor;
pub mod post_flight;
pub mod safety_dispatch;
pub mod telemetry;
pub mod types;
@@ -0,0 +1,97 @@
//! AZ-681 — concrete [`MissionSafetyRouter`] implementation owned by
//! `mission_executor` so `operator_bridge` (Layer 3) can stay free of
//! direct `mission_executor` imports.
//!
//! The composition root constructs a [`SafetyDispatchHandle`] from the
//! BIT controller's `ack` mpsc sender and the battery monitor's handle,
//! then hands an `Arc<dyn MissionSafetyRouter>` to the operator-bridge
//! builder.
//!
//! Mapping (per `architecture.md §F10`):
//!
//! - `acknowledge_bit_degraded` → push a [`BitDegradedAck`] onto the
//! BIT controller's ack channel. The controller validates the
//! `report_id` matches `AwaitingAck`; `operator_bridge` has already
//! validated the signature + checked `BitReportSeverityLookup` to
//! ensure the report is acknowledgeable (NOT `Fail`).
//! - `apply_safety_override` → translate `SafetyOverrideScope` into the
//! subsystem-specific override. Only `BatteryRtl` is supported in
//! AZ-681 (other failsafe families add their own paths later); the
//! hard-floor land-now is NEVER suppressible regardless of scope.
use std::time::Duration;
use async_trait::async_trait;
use tokio::sync::mpsc;
use tokio::time::Instant;
use shared::contracts::MissionSafetyRouter;
use shared::error::{AutopilotError, Result};
use shared::models::operator::SafetyOverrideScope;
use uuid::Uuid;
use crate::internal::battery_thresholds::{BatteryMonitorHandle, BatteryOverride};
use crate::internal::bit::BitDegradedAck;
/// Concrete dispatcher for safety-critical operator commands. Owns
/// only the handles it needs; do not stuff additional concerns here.
#[derive(Clone)]
pub struct SafetyDispatchHandle {
bit_ack_tx: mpsc::Sender<BitDegradedAck>,
battery: BatteryMonitorHandle,
}
impl SafetyDispatchHandle {
pub fn new(bit_ack_tx: mpsc::Sender<BitDegradedAck>, battery: BatteryMonitorHandle) -> Self {
Self {
bit_ack_tx,
battery,
}
}
}
#[async_trait]
impl MissionSafetyRouter for SafetyDispatchHandle {
async fn acknowledge_bit_degraded(
&self,
report_id: Uuid,
operator_id: Option<String>,
) -> Result<()> {
self.bit_ack_tx
.send(BitDegradedAck {
report_id,
operator_id,
})
.await
.map_err(|e| AutopilotError::Internal(format!("bit ack channel closed: {e}")))
}
async fn apply_safety_override(
&self,
scope: SafetyOverrideScope,
duration_secs: u32,
operator_id: String,
rationale: String,
) -> Result<()> {
match scope {
SafetyOverrideScope::BatteryRtl => {
let until = Instant::now() + Duration::from_secs(u64::from(duration_secs));
self.battery
.apply_override(BatteryOverride {
until,
operator_id,
rationale,
})
.await
}
// `SafetyOverrideScope` is `#[non_exhaustive]`; future
// variants (e.g. `LinkLost`, `Geofence`) MUST be wired
// explicitly here before they become usable. Until then,
// surface a typed Validation error so `operator_bridge`
// can NACK to the operator UI.
other => Err(AutopilotError::Validation(format!(
"safety override scope {other:?} not wired in mission_executor"
))),
}
}
}