[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
+54
View File
@@ -0,0 +1,54 @@
//! AZ-680 / AZ-681 — the typed acknowledgement returned by every
//! dispatched operator command.
//!
//! The dispatcher does NOT propagate downstream errors verbatim into
//! the operator UI — the surface here is a small fixed enum so the
//! UI can colour-code the result and so the idempotency cache key
//! space stays bounded.
use serde::{Deserialize, Serialize};
/// Stable kebab-case reason strings emitted in
/// [`CommandAck::Error::reason`]. Exposed as constants so the unit +
/// integration tests can reference them without retyping the strings
/// (drift between caller assertions and the actual emit site has bit
/// us before).
pub mod ack_reasons {
pub const UNKNOWN_POI_ID: &str = "unknown_poi_id";
pub const EXPIRED: &str = "expired";
pub const CANNOT_ACKNOWLEDGE_FAIL: &str = "cannot_acknowledge_fail";
pub const UNKNOWN_BIT_REPORT: &str = "unknown_bit_report";
pub const INVALID_PAYLOAD: &str = "invalid_payload";
pub const ROUTER_NOT_WIRED: &str = "router_not_wired";
pub const ROUTER_ERROR: &str = "router_error";
pub const UNSUPPORTED_KIND: &str = "unsupported_kind";
}
/// Result of a dispatched operator command. Carries either `Ok` or a
/// typed `Error { reason }` whose `reason` string is one of the
/// kebab-case constants in [`ack_reasons`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CommandAck {
Ok,
Error { reason: String },
}
impl CommandAck {
pub fn error(reason: &str) -> Self {
Self::Error {
reason: reason.to_string(),
}
}
pub fn is_ok(&self) -> bool {
matches!(self, Self::Ok)
}
pub fn reason(&self) -> Option<&str> {
match self {
Self::Ok => None,
Self::Error { reason } => Some(reason.as_str()),
}
}
}
@@ -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"));
}
}
@@ -0,0 +1,386 @@
//! AZ-680 + AZ-681 — operator-command dispatcher.
//!
//! Sits between the validated-command boundary (AZ-678) and the
//! downstream routers. Responsibilities:
//!
//! - Per-`command_id` idempotency (60 s TTL — AZ-680 AC-2).
//! - POI-id validity + deadline checks for POI-bound commands
//! (AZ-680 AC-3 / AC-4).
//! - BIT-report severity gate for `AcknowledgeBitDegraded`
//! (AZ-681 AC-2).
//! - Routing — POI commands → `ScanCommandRouter`, BIT acks +
//! safety overrides → `MissionSafetyRouter`.
//! - Audit logging for every safety-critical command
//! (AZ-681 AC-3 / AC-4).
//!
//! The dispatcher OWNS the registry / cache / audit sink and is
//! constructed once by the composition root. It is cheap to clone
//! (all internals are `Arc`s).
use std::sync::Arc;
use chrono::Utc;
use serde::Deserialize;
use uuid::Uuid;
use shared::contracts::{BitReportSeverityLookup, MissionSafetyRouter, ScanCommandRouter};
use shared::models::operator::{OperatorCommand, OperatorCommandKind, SafetyOverrideScope};
use crate::ack::{ack_reasons, CommandAck};
use crate::internal::audit::{AuditEntry, AuditSink, TracingAuditSink};
use crate::internal::idempotency::IdempotencyCache;
use crate::internal::poi_registry::SurfacedPoiRegistry;
#[derive(Clone)]
pub struct OperatorCommandDispatcher {
pub(crate) registry: SurfacedPoiRegistry,
cache: IdempotencyCache,
audit: Arc<dyn AuditSink>,
scan_router: Option<Arc<dyn ScanCommandRouter>>,
safety_router: Option<Arc<dyn MissionSafetyRouter>>,
bit_severity: Option<Arc<dyn BitReportSeverityLookup>>,
}
impl OperatorCommandDispatcher {
pub fn builder() -> OperatorCommandDispatcherBuilder {
OperatorCommandDispatcherBuilder::default()
}
/// Public test helper: peek into the idempotency cache. Used by
/// the integration tests to assert AC-2 ("re-transmit returns
/// cached ack").
#[doc(hidden)]
pub fn cache_len(&self) -> usize {
self.cache.len()
}
/// AZ-680 / AZ-681 — dispatch one validated command. Returns the
/// typed [`CommandAck`]. Idempotency is handled inside; callers
/// just re-submit the same `command_id` on retransmit.
pub async fn dispatch(&self, cmd: OperatorCommand) -> CommandAck {
let cmd_id = cmd.command_id;
self.cache
.get_or_insert_with(cmd_id, || async move { self.dispatch_inner(cmd).await })
.await
}
async fn dispatch_inner(&self, cmd: OperatorCommand) -> CommandAck {
match cmd.kind {
OperatorCommandKind::ConfirmPoi
| OperatorCommandKind::DeclinePoi
| OperatorCommandKind::StartTargetFollow => self.dispatch_poi_bound(cmd).await,
OperatorCommandKind::ReleaseTargetFollow => self.dispatch_via_scan_router(cmd).await,
OperatorCommandKind::AcknowledgeBitDegraded => self.dispatch_bit_ack(cmd).await,
OperatorCommandKind::SafetyOverride => self.dispatch_safety_override(cmd).await,
OperatorCommandKind::MissionAbort => self.dispatch_via_scan_router(cmd).await,
}
}
/// POI-bound dispatch path: enforces `unknown_poi_id` (AC-3) +
/// `expired` (AC-4) before forwarding to `scan_controller`.
async fn dispatch_poi_bound(&self, cmd: OperatorCommand) -> CommandAck {
let poi_id = match poi_id_from_payload(&cmd.payload) {
Ok(id) => id,
Err(_) => return CommandAck::error(ack_reasons::INVALID_PAYLOAD),
};
let Some(surfaced) = self.registry.get(poi_id) else {
return CommandAck::error(ack_reasons::UNKNOWN_POI_ID);
};
if surfaced.deadline <= Utc::now() {
return CommandAck::error(ack_reasons::EXPIRED);
}
self.dispatch_via_scan_router(cmd).await
}
async fn dispatch_via_scan_router(&self, cmd: OperatorCommand) -> CommandAck {
let Some(router) = self.scan_router.as_ref() else {
return CommandAck::error(ack_reasons::ROUTER_NOT_WIRED);
};
match router.route(cmd).await {
Ok(()) => CommandAck::Ok,
Err(e) => {
tracing::warn!(error = %e, "scan router rejected operator command");
CommandAck::error(ack_reasons::ROUTER_ERROR)
}
}
}
async fn dispatch_bit_ack(&self, cmd: OperatorCommand) -> CommandAck {
let payload = match BitAckPayload::from_value(&cmd.payload) {
Ok(p) => p,
Err(_) => {
let ack = CommandAck::error(ack_reasons::INVALID_PAYLOAD);
self.audit_bit(&cmd, Uuid::nil(), &ack).await;
return ack;
}
};
let ack = self.evaluate_bit_ack(&cmd, &payload).await;
self.audit_bit(&cmd, payload.report_id, &ack).await;
ack
}
async fn evaluate_bit_ack(&self, cmd: &OperatorCommand, payload: &BitAckPayload) -> CommandAck {
let Some(severity) = self.bit_severity.as_ref() else {
return CommandAck::error(ack_reasons::ROUTER_NOT_WIRED);
};
match severity.is_acknowledgeable(payload.report_id).await {
Some(true) => match self.safety_router.as_ref() {
Some(router) => match router
.acknowledge_bit_degraded(payload.report_id, payload.operator_id.clone())
.await
{
Ok(()) => CommandAck::Ok,
Err(e) => {
tracing::warn!(error = %e, "mission safety router rejected bit ack");
CommandAck::error(ack_reasons::ROUTER_ERROR)
}
},
None => CommandAck::error(ack_reasons::ROUTER_NOT_WIRED),
},
Some(false) => CommandAck::error(ack_reasons::CANNOT_ACKNOWLEDGE_FAIL),
None => {
tracing::warn!(
command_id = %cmd.command_id,
report_id = %payload.report_id,
"bit_degraded_ack: unknown report id"
);
CommandAck::error(ack_reasons::UNKNOWN_BIT_REPORT)
}
}
}
async fn dispatch_safety_override(&self, cmd: OperatorCommand) -> CommandAck {
let payload = match SafetyOverridePayload::from_value(&cmd.payload) {
Ok(p) => p,
Err(_) => {
let ack = CommandAck::error(ack_reasons::INVALID_PAYLOAD);
self.audit_safety(&cmd, None, 0, &ack).await;
return ack;
}
};
let ack = self.apply_safety_override(&payload).await;
self.audit_safety(&cmd, Some(payload.scope), payload.duration_secs, &ack)
.await;
ack
}
async fn apply_safety_override(&self, payload: &SafetyOverridePayload) -> CommandAck {
let Some(router) = self.safety_router.as_ref() else {
return CommandAck::error(ack_reasons::ROUTER_NOT_WIRED);
};
match router
.apply_safety_override(
payload.scope,
payload.duration_secs,
payload.operator_id.clone(),
payload.rationale.clone(),
)
.await
{
Ok(()) => CommandAck::Ok,
Err(e) => {
tracing::warn!(error = %e, "mission safety router rejected safety override");
CommandAck::error(ack_reasons::ROUTER_ERROR)
}
}
}
async fn audit_bit(&self, cmd: &OperatorCommand, report_id: Uuid, outcome: &CommandAck) {
self.audit
.record(AuditEntry::BitDegradedAck {
command_id: cmd.command_id,
timestamp: Utc::now(),
operator_id: cmd
.payload
.get("operator_id")
.and_then(|v| v.as_str())
.map(String::from),
report_id,
outcome: outcome.clone(),
})
.await;
}
async fn audit_safety(
&self,
cmd: &OperatorCommand,
scope: Option<SafetyOverrideScope>,
duration_secs: u32,
outcome: &CommandAck,
) {
self.audit
.record(AuditEntry::SafetyOverride {
command_id: cmd.command_id,
timestamp: Utc::now(),
operator_id: cmd
.payload
.get("operator_id")
.and_then(|v| v.as_str())
.map(String::from),
scope: scope.unwrap_or(SafetyOverrideScope::BatteryRtl),
duration_secs,
outcome: outcome.clone(),
})
.await;
}
}
// ============================================================================
// Builder
// ============================================================================
#[derive(Default)]
pub struct OperatorCommandDispatcherBuilder {
registry: Option<SurfacedPoiRegistry>,
cache: Option<IdempotencyCache>,
audit: Option<Arc<dyn AuditSink>>,
scan_router: Option<Arc<dyn ScanCommandRouter>>,
safety_router: Option<Arc<dyn MissionSafetyRouter>>,
bit_severity: Option<Arc<dyn BitReportSeverityLookup>>,
}
impl OperatorCommandDispatcherBuilder {
pub fn registry(mut self, r: SurfacedPoiRegistry) -> Self {
self.registry = Some(r);
self
}
pub fn idempotency_cache(mut self, c: IdempotencyCache) -> Self {
self.cache = Some(c);
self
}
pub fn audit_sink(mut self, s: Arc<dyn AuditSink>) -> Self {
self.audit = Some(s);
self
}
pub fn scan_router(mut self, r: Arc<dyn ScanCommandRouter>) -> Self {
self.scan_router = Some(r);
self
}
pub fn safety_router(mut self, r: Arc<dyn MissionSafetyRouter>) -> Self {
self.safety_router = Some(r);
self
}
pub fn bit_severity(mut self, s: Arc<dyn BitReportSeverityLookup>) -> Self {
self.bit_severity = Some(s);
self
}
pub fn build(self) -> OperatorCommandDispatcher {
OperatorCommandDispatcher {
registry: self.registry.unwrap_or_default(),
cache: self
.cache
.unwrap_or_else(IdempotencyCache::with_default_ttl),
audit: self.audit.unwrap_or_else(TracingAuditSink::arc),
scan_router: self.scan_router,
safety_router: self.safety_router,
bit_severity: self.bit_severity,
}
}
}
// ============================================================================
// Payload extraction
// ============================================================================
/// Extract `poi_id` from a POI-bound command payload.
///
/// Wire shape: `{ "poi_id": "<uuid>" }`. Anything else is a hard
/// `invalid_payload` error — the auth layer guarantees the payload
/// bytes weren't tampered with, but the operator UI might still send
/// the wrong shape on a build-skew between client and autopilot.
fn poi_id_from_payload(payload: &serde_json::Value) -> Result<Uuid, ()> {
let v = payload.get("poi_id").and_then(|v| v.as_str()).ok_or(())?;
Uuid::parse_str(v).map_err(|_| ())
}
#[derive(Debug, Deserialize)]
struct BitAckPayload {
report_id: Uuid,
#[serde(default)]
operator_id: Option<String>,
}
impl BitAckPayload {
fn from_value(v: &serde_json::Value) -> Result<Self, serde_json::Error> {
serde_json::from_value(v.clone())
}
}
#[derive(Debug, Deserialize)]
struct SafetyOverridePayload {
scope: SafetyOverrideScope,
duration_secs: u32,
operator_id: String,
#[serde(default)]
rationale: String,
}
impl SafetyOverridePayload {
fn from_value(v: &serde_json::Value) -> Result<Self, serde_json::Error> {
serde_json::from_value(v.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn poi_id_extracts_uuid() {
// Arrange
let id = Uuid::new_v4();
let v = json!({ "poi_id": id.to_string() });
// Act + Assert
assert_eq!(poi_id_from_payload(&v).unwrap(), id);
}
#[test]
fn poi_id_missing_is_err() {
// Arrange
let v = json!({ "other": "x" });
// Act + Assert
assert!(poi_id_from_payload(&v).is_err());
}
#[test]
fn bit_ack_payload_round_trip() {
// Arrange
let id = Uuid::new_v4();
let v = json!({ "report_id": id.to_string(), "operator_id": "op1" });
// Act
let p = BitAckPayload::from_value(&v).expect("parse");
// Assert
assert_eq!(p.report_id, id);
assert_eq!(p.operator_id, Some("op1".to_string()));
}
#[test]
fn safety_override_payload_round_trip() {
// Arrange
let v = json!({
"scope": "battery_rtl",
"duration_secs": 60,
"operator_id": "op1",
"rationale": "post-mission RTL too aggressive"
});
// Act
let p = SafetyOverridePayload::from_value(&v).expect("parse");
// Assert
assert_eq!(p.scope, SafetyOverrideScope::BatteryRtl);
assert_eq!(p.duration_secs, 60);
assert_eq!(p.operator_id, "op1");
}
}
@@ -0,0 +1,173 @@
//! AZ-680 — per-`command_id` idempotency cache.
//!
//! The spec (AC-2): "Re-transmit returns cached ack". A 60 s sliding
//! window over `command_id → CommandAck` so the operator UI can
//! safely retransmit on a flaky modem without causing the autopilot
//! to double-dispatch.
//!
//! Design notes:
//!
//! - Lazy eviction. `get_or_insert_with` purges expired entries before
//! inserting. We do not run a background sweeper task — at the
//! command rate of ≤5 confirms/min (operator workflow), the cache
//! stays small and per-call eviction is cheap.
//! - Returns the *cached* ack on hit; on miss, runs the supplied
//! future, caches its result, returns it. The future is NOT spawned
//! — the caller awaits it.
//! - Cache key is the full `Uuid`; the operator UI generates fresh
//! `command_id`s per logical command, so collisions imply a true
//! retransmit and we want to honour that.
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::time::{Duration, Instant};
use parking_lot::Mutex;
use uuid::Uuid;
use crate::ack::CommandAck;
/// Default TTL per AZ-680 spec.
pub const DEFAULT_IDEMPOTENCY_TTL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
struct Entry {
ack: CommandAck,
cached_at: Instant,
}
/// Bounded-by-TTL idempotency cache. Cheap to `clone` (internals are
/// an `Arc<Mutex<_>>`).
#[derive(Clone)]
pub struct IdempotencyCache {
ttl: Duration,
inner: Arc<Mutex<HashMap<Uuid, Entry>>>,
}
impl IdempotencyCache {
pub fn new(ttl: Duration) -> Self {
Self {
ttl,
inner: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn with_default_ttl() -> Self {
Self::new(DEFAULT_IDEMPOTENCY_TTL)
}
/// Returns the cached ack if `command_id` is present and not
/// expired; otherwise runs `produce`, caches its result, and
/// returns it. Concurrent calls with the same `command_id` MAY
/// each execute `produce` once — that is acceptable here because
/// the downstream routers themselves are idempotent for the same
/// validated payload (the router-level side effect is the same
/// across retries; the registry/queue lookups deduplicate POI
/// state). The cache's primary role is to short-circuit
/// re-transmits that arrive seconds later, not to serialise
/// concurrent dispatchers of the same id.
pub async fn get_or_insert_with<F, Fut>(&self, command_id: Uuid, produce: F) -> CommandAck
where
F: FnOnce() -> Fut,
Fut: Future<Output = CommandAck>,
{
if let Some(cached) = self.get(command_id) {
return cached;
}
let ack = produce().await;
self.insert(command_id, ack.clone());
ack
}
/// Snapshot lookup — also evicts expired entries opportunistically.
pub fn get(&self, command_id: Uuid) -> Option<CommandAck> {
let mut guard = self.inner.lock();
self.evict_expired(&mut guard);
guard.get(&command_id).map(|e| e.ack.clone())
}
fn insert(&self, command_id: Uuid, ack: CommandAck) {
let mut guard = self.inner.lock();
self.evict_expired(&mut guard);
guard.insert(
command_id,
Entry {
ack,
cached_at: Instant::now(),
},
);
}
fn evict_expired(&self, guard: &mut HashMap<Uuid, Entry>) {
let now = Instant::now();
guard.retain(|_, e| now.duration_since(e.cached_at) < self.ttl);
}
pub fn len(&self) -> usize {
let mut guard = self.inner.lock();
self.evict_expired(&mut guard);
guard.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
#[tokio::test]
async fn miss_then_hit_runs_once() {
// Arrange
let cache = IdempotencyCache::with_default_ttl();
let id = Uuid::new_v4();
let count = AtomicU32::new(0);
// Act
let _ = cache
.get_or_insert_with(id, || async {
count.fetch_add(1, Ordering::SeqCst);
CommandAck::Ok
})
.await;
let _ = cache
.get_or_insert_with(id, || async {
count.fetch_add(1, Ordering::SeqCst);
CommandAck::Ok
})
.await;
// Assert
assert_eq!(count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn ttl_expiry_re_runs_producer() {
// Arrange — short TTL to keep the test fast.
let cache = IdempotencyCache::new(Duration::from_millis(20));
let id = Uuid::new_v4();
let count = AtomicU32::new(0);
// Act
let _ = cache
.get_or_insert_with(id, || async {
count.fetch_add(1, Ordering::SeqCst);
CommandAck::Ok
})
.await;
tokio::time::sleep(Duration::from_millis(40)).await;
let _ = cache
.get_or_insert_with(id, || async {
count.fetch_add(1, Ordering::SeqCst);
CommandAck::Ok
})
.await;
// Assert
assert_eq!(count.load(Ordering::SeqCst), 2);
}
}
@@ -1,4 +1,8 @@
//! Internal modules for `operator_bridge`. Not part of the public API.
pub mod audit;
pub mod auth;
pub mod dispatcher;
pub mod idempotency;
pub mod poi_registry;
pub mod poi_surface;
@@ -0,0 +1,128 @@
//! AZ-680 — currently-surfaced POI registry.
//!
//! Tracks the subset of POIs that have been pushed to the operator UI
//! and have not yet been dequeued. The dispatcher consults this
//! registry to reject:
//!
//! - `Confirm` / `Decline` / `StartTargetFollow` for unknown
//! `poi_id`s (AC-3 → `unknown_poi_id`).
//! - Commands whose POI deadline has elapsed (AC-4 → `expired`).
//!
//! The registry is intentionally a plain `HashMap` behind a
//! [`parking_lot::Mutex`] — the dispatcher's lock window is short
//! (one O(1) lookup + one O(1) remove). A `RwLock` would not buy us
//! anything because the dispatcher writes on every confirm/decline.
use std::sync::Arc;
use chrono::{DateTime, Utc};
use parking_lot::Mutex;
use std::collections::HashMap;
use uuid::Uuid;
use shared::models::poi::Poi;
/// Snapshot of the POI fields the dispatcher needs to enforce
/// validity + deadline checks without holding a reference to the
/// full [`Poi`] struct.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SurfacedPoi {
pub poi_id: Uuid,
pub mgrs: String,
pub class_group: String,
pub deadline: DateTime<Utc>,
}
impl From<&Poi> for SurfacedPoi {
fn from(poi: &Poi) -> Self {
Self {
poi_id: poi.id,
mgrs: poi.mgrs.clone(),
class_group: poi.class_group.clone(),
deadline: poi.deadline,
}
}
}
/// In-memory registry of surfaced-but-not-dequeued POIs. Cheap to
/// `clone` — internals are an `Arc<Mutex<_>>`.
#[derive(Default, Clone)]
pub struct SurfacedPoiRegistry {
inner: Arc<Mutex<HashMap<Uuid, SurfacedPoi>>>,
}
impl SurfacedPoiRegistry {
pub fn new() -> Self {
Self::default()
}
/// Record a surfaced POI. Overwrites any prior entry with the
/// same id (the POI was re-surfaced after a rotation).
pub fn record(&self, poi: SurfacedPoi) {
self.inner.lock().insert(poi.poi_id, poi);
}
/// Remove a POI from the surfaced set. Called when the POI is
/// dequeued (rotated, aged out, or operator-decided).
pub fn forget(&self, poi_id: Uuid) {
self.inner.lock().remove(&poi_id);
}
/// Look up a surfaced POI. Returns `None` if the id has never
/// been surfaced or has already been dequeued.
pub fn get(&self, poi_id: Uuid) -> Option<SurfacedPoi> {
self.inner.lock().get(&poi_id).cloned()
}
pub fn len(&self) -> usize {
self.inner.lock().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn surfaced(deadline_secs: i64) -> SurfacedPoi {
SurfacedPoi {
poi_id: Uuid::new_v4(),
mgrs: "33UWP05".into(),
class_group: "vehicle".into(),
deadline: Utc::now() + Duration::seconds(deadline_secs),
}
}
#[test]
fn record_then_get_returns_clone() {
// Arrange
let r = SurfacedPoiRegistry::new();
let p = surfaced(120);
r.record(p.clone());
// Act
let got = r.get(p.poi_id).expect("must be present");
// Assert
assert_eq!(got, p);
}
#[test]
fn forget_removes_entry() {
// Arrange
let r = SurfacedPoiRegistry::new();
let p = surfaced(120);
r.record(p.clone());
// Act
r.forget(p.poi_id);
// Assert
assert!(r.get(p.poi_id).is_none());
assert!(r.is_empty());
}
}
+163 -19
View File
@@ -1,4 +1,5 @@
//! `operator_bridge` — POI surfacing + operator command authentication.
//! `operator_bridge` — POI surfacing + operator command authentication
//! + dispatch.
//!
//! Real implementation in this batch:
//! - **AZ-678** `internal::auth::HmacOperatorValidator` — HMAC-SHA256
@@ -7,11 +8,15 @@
//! 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`
//! - **AZ-680** `internal::dispatcher::OperatorCommandDispatcher` —
//! POI-bound dispatch path, per-`command_id` idempotency cache,
//! unknown-POI + expired-deadline gates.
//! - **AZ-681** `internal::dispatcher::OperatorCommandDispatcher` —
//! BIT-degraded ack severity gate + `SafetyOverride` forwarding
//! into `mission_executor` via `MissionSafetyRouter`; structured
//! audit log entry per safety command.
pub mod ack;
pub mod internal;
use std::sync::Arc;
@@ -20,7 +25,10 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use shared::contracts::{OperatorCommandSink, TelemetrySink};
use shared::contracts::{
BitReportSeverityLookup, MissionSafetyRouter, OperatorCommandSink, ScanCommandRouter,
TelemetrySink,
};
use shared::error::{AutopilotError, Result};
use shared::health::{ComponentHealth, HealthLevel};
use shared::models::mission::Coordinate;
@@ -28,9 +36,16 @@ use shared::models::operator::OperatorCommand;
use shared::models::operator_event::{DequeueReason, PhotoMetadata};
use shared::models::poi::Poi;
pub use crate::ack::{ack_reasons, CommandAck};
pub use crate::internal::audit::{AuditEntry, AuditSink, TracingAuditSink};
pub use crate::internal::auth::{
AuthCounters, HmacOperatorValidator, HmacValidatorConfig, REJECTION_REASONS,
};
pub use crate::internal::dispatcher::{
OperatorCommandDispatcher, OperatorCommandDispatcherBuilder,
};
pub use crate::internal::idempotency::{IdempotencyCache, DEFAULT_IDEMPOTENCY_TTL};
pub use crate::internal::poi_registry::{SurfacedPoi, SurfacedPoiRegistry};
pub use crate::internal::poi_surface::{PoiSurfaceMapper, PoiSurfaceMetrics};
const NAME: &str = "operator_bridge";
@@ -71,6 +86,20 @@ pub struct OperatorBridge {
/// `poi_mapper` so legacy callers continue to compile until the
/// composition root wires it in.
validator: Option<Arc<HmacOperatorValidator>>,
/// AZ-680 — currently-surfaced POI registry. Shared between the
/// `surface_poi` / `emit_poi_dequeued` write-side and the
/// dispatcher's POI-id validity check.
poi_registry: SurfacedPoiRegistry,
/// AZ-680 / AZ-681 — command dispatcher. Optional until both the
/// scan + safety routers are wired; without it `dispatch` returns
/// `router_not_wired`.
dispatcher: Option<Arc<OperatorCommandDispatcher>>,
/// Builder-only accumulators for the dispatcher's routers + sink.
/// Consumed in [`OperatorBridge::with_dispatcher`].
scan_router: Option<Arc<dyn ScanCommandRouter>>,
safety_router: Option<Arc<dyn MissionSafetyRouter>>,
bit_severity: Option<Arc<dyn BitReportSeverityLookup>>,
audit_sink: Option<Arc<dyn AuditSink>>,
}
impl OperatorBridge {
@@ -84,6 +113,12 @@ impl OperatorBridge {
target_follow_rx: Some(tf_rx),
poi_mapper: None,
validator: None,
poi_registry: SurfacedPoiRegistry::new(),
dispatcher: None,
scan_router: None,
safety_router: None,
bit_severity: None,
audit_sink: None,
}
}
@@ -97,12 +132,63 @@ impl OperatorBridge {
self
}
/// AZ-680 — wire `scan_controller`'s [`ScanCommandRouter`] impl.
pub fn with_scan_router(mut self, router: Arc<dyn ScanCommandRouter>) -> Self {
self.scan_router = Some(router);
self
}
/// AZ-681 — wire `mission_executor`'s [`MissionSafetyRouter`] impl.
pub fn with_safety_router(mut self, router: Arc<dyn MissionSafetyRouter>) -> Self {
self.safety_router = Some(router);
self
}
/// AZ-681 — wire `mission_executor`'s
/// [`BitReportSeverityLookup`] impl.
pub fn with_bit_severity_lookup(mut self, lookup: Arc<dyn BitReportSeverityLookup>) -> Self {
self.bit_severity = Some(lookup);
self
}
/// AZ-681 — override the default tracing audit sink. Used by
/// integration tests; production wires the default.
pub fn with_audit_sink(mut self, sink: Arc<dyn AuditSink>) -> Self {
self.audit_sink = Some(sink);
self
}
/// AZ-680 / AZ-681 — finalise the dispatcher. Returns `self` so
/// the call can sit at the end of the builder chain. Idempotent
/// (calling twice rebuilds the dispatcher with the most-recent
/// wiring) — this matters because the composition root sometimes
/// re-runs the wiring sequence on subsystem restart.
pub fn with_dispatcher(mut self) -> Self {
let mut builder = OperatorCommandDispatcher::builder().registry(self.poi_registry.clone());
if let Some(r) = self.scan_router.clone() {
builder = builder.scan_router(r);
}
if let Some(r) = self.safety_router.clone() {
builder = builder.safety_router(r);
}
if let Some(s) = self.bit_severity.clone() {
builder = builder.bit_severity(s);
}
if let Some(s) = self.audit_sink.clone() {
builder = builder.audit_sink(s);
}
self.dispatcher = Some(Arc::new(builder.build()));
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(),
poi_registry: self.poi_registry.clone(),
dispatcher: self.dispatcher.clone(),
}
}
@@ -113,6 +199,15 @@ impl OperatorBridge {
pub fn take_target_follow_receiver(&mut self) -> Option<mpsc::Receiver<TargetFollowEvent>> {
self.target_follow_rx.take()
}
/// AZ-680 — clone of the surfaced-POI registry. Exposed so the
/// composition root can pre-seed entries on subsystem restart
/// and so integration tests can register POIs without spinning
/// up a TelemetrySink. The registry is also wired into the
/// dispatcher.
pub fn surfaced_registry(&self) -> SurfacedPoiRegistry {
self.poi_registry.clone()
}
}
#[derive(Clone)]
@@ -123,19 +218,33 @@ pub struct OperatorBridgeHandle {
target_follow_tx: mpsc::Sender<TargetFollowEvent>,
poi_mapper: Option<Arc<PoiSurfaceMapper>>,
validator: Option<Arc<HmacOperatorValidator>>,
/// AZ-680 — registry of surfaced-but-not-dequeued POIs. The
/// dispatcher consults this for unknown-id + deadline checks.
poi_registry: SurfacedPoiRegistry,
dispatcher: Option<Arc<OperatorCommandDispatcher>>,
}
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.
/// AZ-679 + AZ-680 — surface a POI to the operator. Records the
/// POI in the dispatcher's validity registry so subsequent
/// confirm/decline/start-follow commands resolve. The event itself
/// is pushed via the configured `TelemetrySink`.
///
/// Returns `OperatorDecision::Confirmed`/`Declined`/... is NOT
/// the responsibility of this method any more — the decision
/// arrives asynchronously via `dispatch` and the operator UI
/// applies it. The legacy `Result<OperatorDecision>` shape is
/// retained for callers that have not yet migrated; today the
/// method returns `NotImplemented` after the surface emits, and
/// `scan_controller` should use the non-decision-returning path
/// in `surface_poi_with_photo` instead.
pub async fn surface_poi(&self, poi: Poi) -> Result<OperatorDecision> {
match &self.poi_mapper {
Some(mapper) => {
self.poi_registry.record(SurfacedPoi::from(&poi));
mapper.surface(&poi, None).await?;
Err(AutopilotError::NotImplemented(
"operator_bridge::surface_poi → decision loop (AZ-680)",
"operator_bridge::surface_poi → decision is async via dispatch (AZ-680)",
))
}
None => Err(AutopilotError::NotImplemented(
@@ -144,8 +253,9 @@ impl OperatorBridgeHandle {
}
}
/// AZ-679 — surface a POI together with photo metadata (preferred
/// path when the source detection carries an ROI snapshot).
/// AZ-679 + AZ-680 — surface a POI together with photo metadata
/// (preferred path when the source detection carries an ROI
/// snapshot). Records the POI in the dispatcher's registry.
pub async fn surface_poi_with_photo(
&self,
poi: &Poi,
@@ -154,18 +264,39 @@ impl OperatorBridgeHandle {
let mapper = self.poi_mapper.as_ref().ok_or_else(|| {
AutopilotError::Internal("surface_poi_with_photo: telemetry sink not wired".into())
})?;
self.poi_registry.record(SurfacedPoi::from(poi));
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.
/// AZ-679 + AZ-680 — emit a `PoiDequeued` event (rotation /
/// age-out / completion). Removes the POI from the dispatcher's
/// registry so any further confirm/decline for the same id
/// resolves to `unknown_poi_id`.
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())
})?;
self.poi_registry.forget(poi_id);
mapper.emit_dequeued(poi_id, reason).await
}
/// AZ-680 / AZ-681 — dispatch a validated operator command and
/// return the typed [`CommandAck`]. The dispatcher must be wired
/// via `OperatorBridge::with_dispatcher`; without it every
/// command returns `router_not_wired`.
pub async fn dispatch_command(&self, cmd: OperatorCommand) -> CommandAck {
match &self.dispatcher {
Some(d) => d.dispatch(cmd).await,
None => CommandAck::error(ack_reasons::ROUTER_NOT_WIRED),
}
}
/// Test/observability hook: peek the surfaced-POI registry.
#[doc(hidden)]
pub fn surfaced_poi_count(&self) -> usize {
self.poi_registry.len()
}
pub fn poi_metrics(&self) -> Option<PoiSurfaceMetrics> {
self.poi_mapper.as_ref().map(|m| m.metrics())
}
@@ -197,12 +328,25 @@ impl OperatorBridgeHandle {
}
}
/// AZ-680 — wire the bridge into the `OperatorCommandSink` trait so
/// `telemetry_stream`'s downlink can forward validated commands
/// uniformly. The trait surface is binary (`Result<()>`); the typed
/// [`CommandAck`] surfaces through [`OperatorBridgeHandle::dispatch_command`]
/// for callers that need the rejection reason. The trait impl maps:
///
/// - `CommandAck::Ok` → `Ok(())`
/// - `CommandAck::Error { reason }` → `Err(AutopilotError::Validation(reason))`
///
/// This keeps the trait minimal while still propagating actionable
/// rejection reasons to downstream consumers that only see the
/// trait surface.
#[async_trait]
impl OperatorCommandSink for OperatorBridgeHandle {
async fn dispatch(&self, _command: OperatorCommand) -> Result<()> {
Err(AutopilotError::NotImplemented(
"operator_bridge::dispatch (AZ-680)",
))
async fn dispatch(&self, command: OperatorCommand) -> Result<()> {
match self.dispatch_command(command).await {
CommandAck::Ok => Ok(()),
CommandAck::Error { reason } => Err(AutopilotError::Validation(reason)),
}
}
}