[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
+1
View File
@@ -20,3 +20,4 @@ serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
@@ -66,6 +66,22 @@ pub struct DeclineAction {
pub class_group: String,
}
/// AZ-680 — information returned when a POI is confirmed (or selected
/// for target-follow start). Mirrors [`DeclineAction`] so consumers
/// downstream of the confirm path (AZ-684 evidence ladder, AZ-685
/// mapobjects dispatch, AZ-686 gimbal issuance) get a typed
/// `(target_mgrs, target_class)` hint without re-querying the queue.
///
/// The POI is removed from the queue as part of `confirm`. A
/// subsequent confirm with the same `poi_id` returns `None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfirmAction {
pub poi_id: Uuid,
pub target_mgrs: String,
pub target_class: String,
pub class_group: String,
}
impl PoiQueue {
pub fn new() -> Self {
Self::default()
@@ -145,6 +161,23 @@ impl PoiQueue {
})
}
/// Confirm a POI by id. Removes from queue; returns the typed
/// `(target_mgrs, target_class)` hint that downstream consumers
/// (AZ-684 evidence ladder, AZ-686 gimbal issuance) build the
/// follow-up plan from. AZ-680 only needs the removal + the hint
/// to be carried back through `submit_operator_cmd`'s return
/// value.
pub fn confirm(&mut self, poi_id: Uuid) -> Option<ConfirmAction> {
let idx = self.entries.iter().position(|e| e.poi.id == poi_id)?;
let entry = self.entries.swap_remove(idx);
Some(ConfirmAction {
poi_id: entry.poi.id,
target_mgrs: entry.poi.mgrs,
target_class: entry.poi.class,
class_group: entry.poi.class_group,
})
}
/// Drop POIs whose deadline (set at insertion by the caller per
/// the confidence-scaled window) has elapsed. Returns the IDs of
/// forgotten POIs. NO `IgnoredItem` is created — timeout =
+64 -21
View File
@@ -31,10 +31,12 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use uuid::Uuid;
use shared::contracts::ScanCommandRouter;
use shared::error::{AutopilotError, Result};
use shared::health::{ComponentHealth, HealthLevel};
use shared::models::operator::{OperatorCommand, OperatorCommandKind};
@@ -44,7 +46,8 @@ pub mod internal;
pub use internal::frame_rate_guard::{FrameRateGuard, FrameRateGuardConfig};
pub use internal::poi_queue::{
age_factor, decision_window, priority_score, DeclineAction, PoiQueue, SURFACE_CAP_PER_WINDOW,
age_factor, decision_window, priority_score, ConfirmAction, DeclineAction, PoiQueue,
SURFACE_CAP_PER_WINDOW,
};
pub use internal::state_machine::transitions::{transition, TransitionCtx};
pub use internal::state_machine::{RejectReason, ScanState, TransitionOutcome, Trigger};
@@ -153,11 +156,14 @@ pub struct ScanMetrics {
/// Result of [`ScanControllerHandle::submit_operator_cmd`]. `Accepted`
/// means the command was applied with no return data; `Declined`
/// carries the dispatchable IgnoredItem action AZ-685 must persist.
/// carries the dispatchable IgnoredItem action AZ-685 must persist;
/// `Confirmed` carries the typed `(target_mgrs, target_class)` hint
/// AZ-684 / AZ-686 build a follow-up plan from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubmitOutcome {
Accepted,
Declined(DeclineAction),
Confirmed(ConfirmAction),
}
fn poi_id_from_payload(payload: &serde_json::Value) -> Result<Uuid> {
@@ -268,6 +274,18 @@ impl ScanControllerHandle {
action
}
/// AZ-680 — confirm a POI (or target-follow start). Looks up the
/// POI by id, removes it from the queue, and returns the typed
/// `(target_mgrs, target_class)` hint for downstream consumers.
///
/// The FSM-side follow-through (zoom-in trigger, target-follow
/// transition) is AZ-684's evidence-ladder scope and is NOT
/// performed here — this method only resolves the queue entry.
pub async fn confirm_poi(&self, poi_id: Uuid) -> Option<ConfirmAction> {
let mut inner = self.inner.lock().await;
inner.poi_queue.confirm(poi_id)
}
pub async fn poi_queue_len(&self) -> usize {
self.inner.lock().await.poi_queue.len()
}
@@ -279,20 +297,24 @@ impl ScanControllerHandle {
/// Translate an operator command into a trigger and apply it.
///
/// AZ-682 / AZ-683 mapping (subset complete):
/// Mapping (AZ-682 / AZ-683 / AZ-680):
///
/// - `MissionAbort` → `Trigger::OperatorAbort` (AZ-682).
/// - `ReleaseTargetFollow` → `Trigger::OperatorReleaseFollow`
/// (AZ-682).
/// - `DeclinePoi { poi_id }` → queue decline; returns the
/// resulting `DeclineAction` in [`SubmitOutcome::Declined`]
/// for the caller (AZ-685 mapobjects dispatch) to persist
/// (AZ-683).
/// - `ConfirmPoi` / `StartTargetFollow` → still
/// `NotImplemented(AZ-684)` since ROI / target_id resolution
/// needs the evidence ladder.
/// - `AcknowledgeBitDegraded` / `SafetyOverride` →
/// `NotImplemented(AZ-684)`.
/// - `DeclinePoi { poi_id }` → queue decline; returns
/// [`SubmitOutcome::Declined`] for the caller (AZ-685
/// mapobjects dispatch) to persist (AZ-683).
/// - `ConfirmPoi { poi_id }` / `StartTargetFollow { poi_id }` →
/// queue lookup + removal; returns
/// [`SubmitOutcome::Confirmed`] carrying the typed
/// `(target_mgrs, target_class)` hint (AZ-680). The FSM-side
/// follow-through (zoom-in trigger, target-follow transition)
/// is AZ-684's scope.
/// - `AcknowledgeBitDegraded` / `SafetyOverride` are NOT
/// handled here — those go to `mission_executor` via the
/// `MissionSafetyRouter` path wired by `operator_bridge`
/// (AZ-681). Receiving one in this method is a routing bug.
pub async fn submit_operator_cmd(&self, command: OperatorCommand) -> Result<SubmitOutcome> {
match command.kind {
OperatorCommandKind::MissionAbort => {
@@ -313,16 +335,21 @@ impl ScanControllerHandle {
}
}
OperatorCommandKind::ConfirmPoi | OperatorCommandKind::StartTargetFollow => {
Err(AutopilotError::NotImplemented(
"scan_controller::submit_operator_cmd (AZ-684 evidence ladder)",
))
let poi_id = poi_id_from_payload(&command.payload)?;
match self.confirm_poi(poi_id).await {
Some(action) => Ok(SubmitOutcome::Confirmed(action)),
None => Err(AutopilotError::Validation(format!(
"{:?}: unknown poi_id {poi_id}",
command.kind
))),
}
}
OperatorCommandKind::AcknowledgeBitDegraded | OperatorCommandKind::SafetyOverride => {
Err(AutopilotError::Validation(format!(
"scan_controller does not handle {:?}; route via MissionSafetyRouter",
command.kind
)))
}
OperatorCommandKind::AcknowledgeBitDegraded => Err(AutopilotError::NotImplemented(
"scan_controller::submit_operator_cmd (AZ-684 evidence ladder)",
)),
OperatorCommandKind::SafetyOverride => Err(AutopilotError::NotImplemented(
"scan_controller::submit_operator_cmd (AZ-684 evidence ladder)",
)),
}
}
@@ -400,6 +427,22 @@ impl ScanControllerHandle {
}
}
/// AZ-680 — adapter for the `shared::contracts::ScanCommandRouter`
/// trait so `operator_bridge` (Layer 3) can dispatch operator
/// commands into `scan_controller` (Layer 4) without importing this
/// crate directly. Forwards to the inherent
/// [`ScanControllerHandle::submit_operator_cmd`] and discards the
/// `SubmitOutcome` (the trait surface is intentionally minimal —
/// `operator_bridge` does not need the typed hint; AZ-685 wires the
/// `Confirmed`/`Declined` actions into `mapobjects_store` through a
/// different path).
#[async_trait]
impl ScanCommandRouter for ScanControllerHandle {
async fn route(&self, command: OperatorCommand) -> Result<()> {
self.submit_operator_cmd(command).await.map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
+67 -1
View File
@@ -153,7 +153,73 @@ async fn decline_poi_via_operator_command_emits_action() {
assert_eq!(action.mgrs, "decline-me");
assert_eq!(action.class_group, "armor");
}
SubmitOutcome::Accepted => panic!("decline must return Declined action"),
other => panic!("decline must return Declined action, got {other:?}"),
}
assert_eq!(h.poi_queue_len().await, 0);
}
/// AZ-680 — ConfirmPoi via operator command returns
/// `SubmitOutcome::Confirmed` with the typed target hint and drains
/// the POI from the queue.
#[tokio::test]
async fn confirm_poi_via_operator_command_emits_action() {
// Arrange
let h = ScanController::new().handle();
let p = poi(0.8, "confirm-me");
let id = p.id;
let expected_class = p.class.clone();
let expected_group = p.class_group.clone();
h.submit_poi_candidate(p, 0.5).await;
let cmd = OperatorCommand {
command_id: Uuid::new_v4(),
session_token: "s".to_string(),
sequence_number: 1,
issued_at_wallclock: Utc::now(),
kind: OperatorCommandKind::ConfirmPoi,
payload: json!({ "poi_id": id.to_string() }),
signature: vec![],
};
// Act
let outcome = h.submit_operator_cmd(cmd).await.expect("confirm accepted");
// Assert
match outcome {
SubmitOutcome::Confirmed(action) => {
assert_eq!(action.poi_id, id);
assert_eq!(action.target_mgrs, "confirm-me");
assert_eq!(action.target_class, expected_class);
assert_eq!(action.class_group, expected_group);
}
other => panic!("confirm must return Confirmed action, got {other:?}"),
}
assert_eq!(h.poi_queue_len().await, 0);
}
/// AZ-680 — ConfirmPoi for an unknown poi_id must NOT silently
/// succeed. Returns a `Validation` error so `operator_bridge` can
/// surface a typed NACK to the operator UI.
#[tokio::test]
async fn confirm_poi_unknown_id_is_validation_error() {
// Arrange
let h = ScanController::new().handle();
let cmd = OperatorCommand {
command_id: Uuid::new_v4(),
session_token: "s".to_string(),
sequence_number: 1,
issued_at_wallclock: Utc::now(),
kind: OperatorCommandKind::ConfirmPoi,
payload: json!({ "poi_id": Uuid::new_v4().to_string() }),
signature: vec![],
};
// Act
let err = h
.submit_operator_cmd(cmd)
.await
.expect_err("unknown poi must error");
// Assert
assert!(matches!(err, shared::error::AutopilotError::Validation(_)));
}