[AZ-676] [AZ-677] [AZ-678] [AZ-679] telemetry+operator foundation

Batch 15 ships the four foundation tickets sitting on top of AZ-675
(gRPC server) and AZ-667 (mapobjects_store hydrate):

* AZ-676: telemetry_stream video path (rtsp_forward + bytes_inline)
  with ai_locked atomic + session counter, SubscribeVideo RPC.
* AZ-677: MapObjects snapshot-on-subscribe + diff broadcast +
  reconnect-resync (StartThen stream-prepend pattern).
* AZ-678: HmacOperatorValidator with per-session monotonic seq,
  in-process session registry + TTL, constant-time HMAC compare,
  rejection-reason counters, sliding 60 s sig-failure red-health gate.
  Trait OperatorCommandValidator in shared::contracts::operator_auth.
* AZ-679: PoiSurfaceMapper produces OperatorPoiEvent per architecture
  §7.10; PoiDequeued events on rotate/age-out/complete; pushed via
  new TelemetrySink::push_operator_event extension on Topic::OperatorEvent.

Cross-task wiring: TelemetrySink trait extended with
push_operator_event; OperatorBridge gets optional builder methods
with_telemetry_sink / with_validator (composition root wires in
AZ-680). Workspace deps: hmac = "0.12"; per-crate adds bytes,
serde_json, parking_lot, chrono, uuid, sha2, thiserror.

Tests: 14/14 ACs verified locally (4 + 3 + 5 + 3 by AC) plus
6 supporting unit tests + 7 integration tests + 2 shared serde
roundtrips. cargo clippy clean on touched crates. Cumulative
review for batches 13-15 produced; verdict PASS_WITH_WARNINGS
(0 Critical, 0 High, 1 Medium, 4 Low — all carry-overs or
deferred-producer notes for AZ-680/AZ-684).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-20 16:18:40 +03:00
parent 0eb09eec2d
commit ccf929af69
29 changed files with 3495 additions and 68 deletions
+531
View File
@@ -0,0 +1,531 @@
//! AZ-678 — default operator-command authentication.
//!
//! `HmacOperatorValidator` implements
//! `shared::contracts::operator_auth::OperatorCommandValidator` using
//! HMAC-SHA256 over `(session_token || sequence_number ||
//! canonical_payload_json)`. It carries:
//! - a per-session in-memory `SessionRegistry` (added on Ground
//! Station auth handshake; expired after `session_ttl`);
//! - a per-session monotonically advancing sequence-number tracker
//! (replay protection);
//! - per-reason rejection counters + a sliding-window red-health
//! gate on sustained signature failures (per AC-5).
//!
//! Constant-time HMAC compare via `hmac::Mac::verify_slice` — no
//! timing oracle. Rejected commands are NEVER logged at info level
//! with raw payload; only the rejection reason and size-capped
//! command_id are emitted.
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use parking_lot::Mutex;
use sha2::Sha256;
use tracing::warn;
use shared::contracts::operator_auth::{
AuthError, OperatorCommandValidator, SignedCommand, ValidatedCommand,
};
type HmacSha256 = Hmac<Sha256>;
/// Ordered set of rejection reasons. Drives the `auth_rejections_total`
/// counter array layout and the [`AuthCounters::by_reason`] lookup.
pub const REJECTION_REASONS: [AuthError; 4] = [
AuthError::SignatureInvalid,
AuthError::ReplayDetected,
AuthError::SessionUnknown,
AuthError::SessionExpired,
];
/// Per-session state — last-seen sequence number for replay
/// protection plus the wall-clock + monotonic anchor for TTL.
#[derive(Debug, Clone)]
struct SessionEntry {
secret: Vec<u8>,
/// `Some(n)` once we have observed at least one accepted command
/// from the session. `None` means the session is registered but
/// the next accepted seq is the floor — `>= 1` per the wire
/// contract.
last_seen_seq: Option<u64>,
established_at: Instant,
established_wallclock: DateTime<Utc>,
}
/// Configuration knobs for the HMAC validator.
#[derive(Debug, Clone)]
pub struct HmacValidatorConfig {
/// Session lifetime starting from `register_session`. After this
/// elapses any command bearing the token is rejected with
/// `SessionExpired`. Default 30 minutes per architecture §5.
pub session_ttl: Duration,
/// Per-minute signature-failure threshold above which
/// `health_is_red` returns `true` (AC-5). Default 30 — i.e. one
/// failure every two seconds sustained for a minute.
pub signature_failure_red_threshold: u32,
}
impl Default for HmacValidatorConfig {
fn default() -> Self {
Self {
session_ttl: Duration::from_secs(30 * 60),
signature_failure_red_threshold: 30,
}
}
}
/// Live rejection counters. Exposed to the health surface; one entry
/// per `REJECTION_REASONS` slot.
#[derive(Debug, Default)]
pub struct AuthCounters {
by_reason: [AtomicU64; REJECTION_REASONS.len()],
total_validated: AtomicU64,
}
impl AuthCounters {
pub fn reason(&self, e: AuthError) -> u64 {
let idx = REJECTION_REASONS
.iter()
.position(|r| *r == e)
.expect("REJECTION_REASONS covers every AuthError variant");
self.by_reason[idx].load(Ordering::Relaxed)
}
pub fn validated_total(&self) -> u64 {
self.total_validated.load(Ordering::Relaxed)
}
fn increment(&self, e: AuthError) {
let idx = REJECTION_REASONS
.iter()
.position(|r| *r == e)
.expect("REJECTION_REASONS covers every AuthError variant");
self.by_reason[idx].fetch_add(1, Ordering::Relaxed);
}
fn increment_validated(&self) {
self.total_validated.fetch_add(1, Ordering::Relaxed);
}
}
/// HMAC validator state — sessions + counters + signature-failure
/// sliding window for the red-health gate.
pub struct HmacOperatorValidator {
config: HmacValidatorConfig,
sessions: Mutex<HashMap<String, SessionEntry>>,
/// Signature-failure timestamps in the trailing 60 s window.
/// Bounded by either the config threshold * 2 (defense against
/// flooding) or 60 s of trailing history, whichever comes first.
sig_failure_window: Mutex<VecDeque<Instant>>,
counters: Arc<AuthCounters>,
}
impl HmacOperatorValidator {
pub fn new(config: HmacValidatorConfig) -> Self {
Self {
config,
sessions: Mutex::new(HashMap::new()),
sig_failure_window: Mutex::new(VecDeque::new()),
counters: Arc::new(AuthCounters::default()),
}
}
pub fn with_default_config() -> Self {
Self::new(HmacValidatorConfig::default())
}
/// Register a session — called on Ground Station auth handshake.
/// Replacing an existing session for the same token is allowed
/// (rotates the secret and resets the replay tracker).
pub fn register_session(&self, token: impl Into<String>, secret: impl Into<Vec<u8>>) {
let token = token.into();
let entry = SessionEntry {
secret: secret.into(),
last_seen_seq: None,
established_at: Instant::now(),
established_wallclock: Utc::now(),
};
self.sessions.lock().insert(token, entry);
}
/// Drop a session (operator logout / explicit revoke).
pub fn revoke_session(&self, token: &str) -> bool {
self.sessions.lock().remove(token).is_some()
}
pub fn counters(&self) -> Arc<AuthCounters> {
Arc::clone(&self.counters)
}
pub fn config(&self) -> &HmacValidatorConfig {
&self.config
}
/// True when the trailing 60-second window of signature failures
/// is at or above the configured red threshold (AC-5). Pruning of
/// expired entries happens on every call.
pub fn health_is_red(&self) -> bool {
let now = Instant::now();
let mut w = self.sig_failure_window.lock();
while let Some(&t) = w.front() {
if now.duration_since(t) > Duration::from_secs(60) {
w.pop_front();
} else {
break;
}
}
w.len() >= self.config.signature_failure_red_threshold as usize
}
/// Helper that recomputes the canonical signing material. Public
/// so the Ground Station side can co-locate the spec.
pub fn signing_material(
session_token: &str,
sequence_number: u64,
payload: &serde_json::Value,
) -> Vec<u8> {
let payload_bytes = serde_json::to_vec(payload).unwrap_or_default();
let mut buf = Vec::with_capacity(session_token.len() + 8 + payload_bytes.len() + 2);
buf.extend_from_slice(session_token.as_bytes());
buf.push(b'|');
buf.extend_from_slice(&sequence_number.to_be_bytes());
buf.push(b'|');
buf.extend_from_slice(&payload_bytes);
buf
}
/// Helper that produces the HMAC tag for a `(token, seq, payload)`
/// triple under `secret`. Used by tests and by the Ground Station
/// reference implementation.
pub fn sign(
secret: &[u8],
session_token: &str,
seq: u64,
payload: &serde_json::Value,
) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(&Self::signing_material(session_token, seq, payload));
mac.finalize().into_bytes().to_vec()
}
fn record_sig_failure(&self, now: Instant) {
let mut w = self.sig_failure_window.lock();
w.push_back(now);
// Prune old entries opportunistically so the window doesn't
// grow unbounded under a flood.
while let Some(&t) = w.front() {
if now.duration_since(t) > Duration::from_secs(60) {
w.pop_front();
} else {
break;
}
}
}
}
impl OperatorCommandValidator for HmacOperatorValidator {
fn validate(&self, cmd: SignedCommand) -> Result<ValidatedCommand, AuthError> {
// Step 1 — session lookup. Failure does NOT touch the replay
// counter (the command never authenticated, so nothing to
// advance).
let mut sessions = self.sessions.lock();
let entry = match sessions.get_mut(&cmd.session_token) {
Some(e) => e,
None => {
self.counters.increment(AuthError::SessionUnknown);
drop(sessions);
warn!(
command_id = %cmd.command_id,
reason = AuthError::SessionUnknown.reason_label(),
"operator command rejected"
);
return Err(AuthError::SessionUnknown);
}
};
// Step 2 — TTL check. We check both monotonic age (Instant)
// and the configured TTL. Wall-clock skew is not used.
if entry.established_at.elapsed() > self.config.session_ttl {
self.counters.increment(AuthError::SessionExpired);
// Strip the session so subsequent commands skip the TTL
// path and just see SessionUnknown.
sessions.remove(&cmd.session_token);
drop(sessions);
warn!(
command_id = %cmd.command_id,
reason = AuthError::SessionExpired.reason_label(),
"operator command rejected"
);
return Err(AuthError::SessionExpired);
}
// Step 3 — replay check. We compare against the per-session
// `last_seen_seq`; the rejected seq is NOT recorded so a
// legitimate retry can still land with the next valid seq.
if let Some(last) = entry.last_seen_seq {
if cmd.sequence_number <= last {
self.counters.increment(AuthError::ReplayDetected);
drop(sessions);
warn!(
command_id = %cmd.command_id,
last_seen = last,
seq = cmd.sequence_number,
reason = AuthError::ReplayDetected.reason_label(),
"operator command rejected"
);
return Err(AuthError::ReplayDetected);
}
}
// Step 4 — HMAC check. Constant-time via `verify_slice`.
let mut mac =
HmacSha256::new_from_slice(&entry.secret).expect("HMAC accepts any key length");
mac.update(&Self::signing_material(
&cmd.session_token,
cmd.sequence_number,
&cmd.payload,
));
let signature_ok = mac.verify_slice(&cmd.signature).is_ok();
if !signature_ok {
self.counters.increment(AuthError::SignatureInvalid);
let _established = entry.established_wallclock;
drop(sessions);
self.record_sig_failure(Instant::now());
warn!(
command_id = %cmd.command_id,
reason = AuthError::SignatureInvalid.reason_label(),
"operator command rejected"
);
return Err(AuthError::SignatureInvalid);
}
// Happy path — advance the per-session sequence tracker.
entry.last_seen_seq = Some(cmd.sequence_number);
drop(sessions);
self.counters.increment_validated();
Ok(ValidatedCommand {
command: cmd.into_command(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use shared::models::operator::OperatorCommandKind;
use uuid::Uuid;
fn signed_command(
secret: &[u8],
session_token: &str,
seq: u64,
payload: serde_json::Value,
) -> SignedCommand {
let sig = HmacOperatorValidator::sign(secret, session_token, seq, &payload);
SignedCommand {
session_token: session_token.to_string(),
sequence_number: seq,
kind: OperatorCommandKind::ConfirmPoi,
payload,
signature: sig,
issued_at_wallclock: Utc::now(),
command_id: Uuid::new_v4(),
}
}
/// AC-1 — valid signature + monotonic seq → Ok; last_seen advances.
#[test]
fn ac1_valid_signed_command_passes() {
// Arrange
let v = HmacOperatorValidator::with_default_config();
let secret = b"unit-test-secret";
v.register_session("tok_a", secret.to_vec());
let cmd = signed_command(secret, "tok_a", 5, serde_json::json!({"poi_id": "u-1"}));
// Act
let out = v.validate(cmd.clone());
// Assert
assert!(out.is_ok(), "valid command must pass");
assert_eq!(v.counters().validated_total(), 1);
// last_seen advanced — a second command with same seq is now
// replay.
let replay = v.validate(cmd);
assert_eq!(replay.unwrap_err(), AuthError::ReplayDetected);
}
/// AC-2 — invalid signature → SignatureInvalid; counter increments;
/// seq NOT advanced; subsequent valid command with same seq passes.
#[test]
fn ac2_invalid_signature_rejected_and_seq_not_advanced() {
// Arrange
let v = HmacOperatorValidator::with_default_config();
let secret = b"unit-test-secret";
v.register_session("tok_b", secret.to_vec());
let bad_payload = serde_json::json!({"poi_id": "u-1"});
let bad_sig = HmacOperatorValidator::sign(b"WRONG-SECRET", "tok_b", 5, &bad_payload);
let bad = SignedCommand {
session_token: "tok_b".to_string(),
sequence_number: 5,
kind: OperatorCommandKind::ConfirmPoi,
payload: bad_payload.clone(),
signature: bad_sig,
issued_at_wallclock: Utc::now(),
command_id: Uuid::new_v4(),
};
// Act
let rejected = v.validate(bad);
let good = signed_command(secret, "tok_b", 5, bad_payload);
let accepted = v.validate(good);
// Assert
assert_eq!(rejected.unwrap_err(), AuthError::SignatureInvalid);
assert_eq!(v.counters().reason(AuthError::SignatureInvalid), 1);
assert!(accepted.is_ok(), "seq=5 must still be valid after sig-fail");
assert_eq!(v.counters().validated_total(), 1);
}
/// AC-3 — seq == last_seen → ReplayDetected; seq < last_seen → also
/// ReplayDetected.
#[test]
fn ac3_replay_detected() {
// Arrange
let v = HmacOperatorValidator::with_default_config();
let secret = b"s";
v.register_session("tok", secret.to_vec());
let _ = v
.validate(signed_command(secret, "tok", 10, serde_json::json!({})))
.unwrap();
// Act
let same = v.validate(signed_command(secret, "tok", 10, serde_json::json!({})));
let earlier = v.validate(signed_command(secret, "tok", 9, serde_json::json!({})));
// Assert
assert_eq!(same.unwrap_err(), AuthError::ReplayDetected);
assert_eq!(earlier.unwrap_err(), AuthError::ReplayDetected);
assert_eq!(v.counters().reason(AuthError::ReplayDetected), 2);
}
/// AC-4 — unknown session token → SessionUnknown; expired session
/// token → SessionExpired.
#[test]
fn ac4_unknown_or_expired_session_rejected() {
// Arrange — TTL set tiny so the session expires within the
// test.
let cfg = HmacValidatorConfig {
session_ttl: Duration::from_millis(10),
..HmacValidatorConfig::default()
};
let v = HmacOperatorValidator::new(cfg);
let secret = b"s";
// Act 1 — unknown token rejected immediately.
let unknown = v.validate(signed_command(
secret,
"no_such_session",
1,
serde_json::json!({}),
));
// Register, wait past TTL, retry.
v.register_session("tok", secret.to_vec());
std::thread::sleep(Duration::from_millis(50));
let expired = v.validate(signed_command(secret, "tok", 1, serde_json::json!({})));
// Assert
assert_eq!(unknown.unwrap_err(), AuthError::SessionUnknown);
assert_eq!(expired.unwrap_err(), AuthError::SessionExpired);
assert_eq!(v.counters().reason(AuthError::SessionUnknown), 1);
assert_eq!(v.counters().reason(AuthError::SessionExpired), 1);
}
/// AC-5 — sustained signature failures (≥ threshold within the
/// trailing 60 s) flip the red-health gate.
#[test]
fn ac5_sustained_signature_failures_flip_health_red() {
// Arrange
let cfg = HmacValidatorConfig {
signature_failure_red_threshold: 5,
..HmacValidatorConfig::default()
};
let v = HmacOperatorValidator::new(cfg);
let secret = b"s";
v.register_session("tok", secret.to_vec());
// Below threshold → green.
for seq in 0..4 {
let bad_sig =
HmacOperatorValidator::sign(b"wrong", "tok", seq + 1, &serde_json::json!({}));
let bad = SignedCommand {
session_token: "tok".to_string(),
sequence_number: seq + 1,
kind: OperatorCommandKind::ConfirmPoi,
payload: serde_json::json!({}),
signature: bad_sig,
issued_at_wallclock: Utc::now(),
command_id: Uuid::new_v4(),
};
let _ = v.validate(bad);
}
assert!(!v.health_is_red(), "4 failures < threshold");
// Act — push one more to reach threshold.
let bad_sig = HmacOperatorValidator::sign(b"wrong", "tok", 100, &serde_json::json!({}));
let bad = SignedCommand {
session_token: "tok".to_string(),
sequence_number: 100,
kind: OperatorCommandKind::ConfirmPoi,
payload: serde_json::json!({}),
signature: bad_sig,
issued_at_wallclock: Utc::now(),
command_id: Uuid::new_v4(),
};
let _ = v.validate(bad);
// Assert
assert!(v.health_is_red(), "≥ threshold → red");
assert_eq!(v.counters().reason(AuthError::SignatureInvalid), 5);
}
/// Constant-time verify: same-length wrong signature must yield
/// SignatureInvalid (not a panic), and the rejection counter
/// increments by one. (Smoke test that `verify_slice` is wired
/// correctly.)
#[test]
fn same_length_wrong_signature_is_rejected_cleanly() {
// Arrange
let v = HmacOperatorValidator::with_default_config();
let secret = b"s";
v.register_session("tok", secret.to_vec());
let payload = serde_json::json!({});
let mut bad_sig = HmacOperatorValidator::sign(secret, "tok", 1, &payload);
// Flip one byte — same length, different value.
bad_sig[0] ^= 0x01;
let cmd = SignedCommand {
session_token: "tok".to_string(),
sequence_number: 1,
kind: OperatorCommandKind::ConfirmPoi,
payload,
signature: bad_sig,
issued_at_wallclock: Utc::now(),
command_id: Uuid::new_v4(),
};
// Act
let r = v.validate(cmd);
// Assert
assert_eq!(r.unwrap_err(), AuthError::SignatureInvalid);
assert_eq!(v.counters().reason(AuthError::SignatureInvalid), 1);
}
}
@@ -0,0 +1,4 @@
//! Internal modules for `operator_bridge`. Not part of the public API.
pub mod auth;
pub mod poi_surface;
@@ -0,0 +1,323 @@
//! AZ-679 — POI surface event mapping + dequeue emission.
//!
//! `PoiSurfaceMapper::map(poi)` produces the
//! [`OperatorPoiEvent`](shared::models::operator_event::OperatorPoiEvent)
//! that the operator UI consumes (per `architecture.md §7.10` and the
//! task spec's field list). On queue rotation / age-out / completion
//! `emit_dequeued` produces a `PoiDequeued` event.
//!
//! Both events are pushed through `TelemetrySink::push_operator_event`
//! — composition root supplies the sink (in production, the
//! `telemetry_stream::TelemetryStreamHandle`).
//!
//! `pois_surfaced_per_min` counter exposed via [`PoiSurfaceMetrics`].
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use chrono::Utc;
use parking_lot::Mutex;
use shared::contracts::TelemetrySink;
use shared::error::{AutopilotError, Result};
use shared::models::operator_event::{
DequeueReason, OperatorEvent, OperatorPoiEvent, PhotoMetadata, PoiDequeued,
Tier2EvidenceSummary,
};
use shared::models::poi::{Poi, VlmPipelineStatus};
use uuid::Uuid;
/// Sliding 60 s window over POI-surfaced timestamps. Used by the
/// `pois_surfaced_per_min` health metric.
#[derive(Default)]
struct SurfaceRateWindow {
timestamps: Mutex<std::collections::VecDeque<std::time::Instant>>,
}
impl SurfaceRateWindow {
fn record_and_count(&self) -> usize {
let now = std::time::Instant::now();
let mut w = self.timestamps.lock();
w.push_back(now);
while let Some(&t) = w.front() {
if now.duration_since(t) > std::time::Duration::from_secs(60) {
w.pop_front();
} else {
break;
}
}
w.len()
}
fn current_rate(&self) -> usize {
let now = std::time::Instant::now();
let mut w = self.timestamps.lock();
while let Some(&t) = w.front() {
if now.duration_since(t) > std::time::Duration::from_secs(60) {
w.pop_front();
} else {
break;
}
}
w.len()
}
}
#[derive(Debug, Clone)]
pub struct PoiSurfaceMetrics {
pub pois_surfaced_per_min: usize,
pub pois_surfaced_total: u64,
pub pois_dequeued_total: u64,
}
pub struct PoiSurfaceMapper {
sink: Arc<dyn TelemetrySink>,
pois_surfaced_total: AtomicU64,
pois_dequeued_total: AtomicU64,
rate: SurfaceRateWindow,
}
impl PoiSurfaceMapper {
pub fn new(sink: Arc<dyn TelemetrySink>) -> Self {
Self {
sink,
pois_surfaced_total: AtomicU64::new(0),
pois_dequeued_total: AtomicU64::new(0),
rate: SurfaceRateWindow::default(),
}
}
/// Pure mapping — produces the wire-format event. Used by tests
/// and by [`surface`] (which also pushes through the sink).
/// Photo metadata is optional and may be supplied by the caller
/// when the POI's source detection has a captured ROI snapshot;
/// the `Poi` model itself does not carry photo bytes.
pub fn map(poi: &Poi, photo_metadata: Option<PhotoMetadata>) -> OperatorPoiEvent {
let tier2_evidence_summary = poi.tier2_evidence.as_ref().map(|t| Tier2EvidenceSummary {
path_freshness: t.path_freshness,
endpoint_score: t.endpoint_score,
concealment_score: t.concealment_score,
recommended_next_action: t.recommended_next_action,
status: t.status,
});
let vlm_label = match poi.vlm_status {
// The Poi model does not carry the VLM label string — only
// the pipeline status. The label is attached upstream
// when the assessment lands in scan_controller; for now
// we surface None and let scan_controller pass the label
// through a richer overload once AZ-684 wires it.
VlmPipelineStatus::Ok => None,
_ => None,
};
OperatorPoiEvent {
poi_id: poi.id,
mgrs: poi.mgrs.clone(),
class_group: poi.class_group.clone(),
confidence: poi.confidence,
vlm_status: poi.vlm_status,
vlm_label,
tier2_evidence_summary,
photo_metadata,
deadline_unix_ms: poi.deadline.timestamp_millis(),
}
}
/// Map + push. Returns the wire event so the caller can also
/// attach it to the audit log if needed.
pub async fn surface(
&self,
poi: &Poi,
photo_metadata: Option<PhotoMetadata>,
) -> Result<OperatorPoiEvent> {
let event = Self::map(poi, photo_metadata);
self.sink
.push_operator_event(OperatorEvent::PoiSurfaced(event.clone()))
.await
.map_err(|e| AutopilotError::Internal(format!("push_operator_event(poi): {e}")))?;
self.pois_surfaced_total.fetch_add(1, Ordering::Relaxed);
self.rate.record_and_count();
Ok(event)
}
/// Emit a `PoiDequeued` event. Called by `scan_controller` (via
/// `operator_bridge`) when a POI is rotated, ages out, or
/// completes (operator decided).
pub async fn emit_dequeued(&self, poi_id: Uuid, reason: DequeueReason) -> Result<()> {
let event = PoiDequeued {
poi_id,
reason,
dequeued_at: Utc::now(),
};
self.sink
.push_operator_event(OperatorEvent::PoiDequeued(event))
.await
.map_err(|e| AutopilotError::Internal(format!("push_operator_event(dequeue): {e}")))?;
self.pois_dequeued_total.fetch_add(1, Ordering::Relaxed);
Ok(())
}
pub fn metrics(&self) -> PoiSurfaceMetrics {
PoiSurfaceMetrics {
pois_surfaced_per_min: self.rate.current_rate(),
pois_surfaced_total: self.pois_surfaced_total.load(Ordering::Relaxed),
pois_dequeued_total: self.pois_dequeued_total.load(Ordering::Relaxed),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use chrono::{Duration, Utc};
use shared::models::detection::DetectionBatch;
use shared::models::frame::Frame;
use shared::models::tier2::{RecommendedNextAction, Tier2Evidence, Tier2Status};
/// Recording sink that captures every operator event pushed to it.
/// Lets tests assert on the exact wire content without spinning
/// up a real gRPC server.
#[derive(Default, Clone)]
struct RecordingSink {
events: Arc<Mutex<Vec<OperatorEvent>>>,
}
#[async_trait]
impl TelemetrySink for RecordingSink {
async fn push_frame(&self, _frame: Frame) -> Result<()> {
Ok(())
}
async fn push_detections(&self, _batch: DetectionBatch) -> Result<()> {
Ok(())
}
async fn push_operator_event(&self, event: OperatorEvent) -> Result<()> {
self.events.lock().push(event);
Ok(())
}
}
fn poi_with_full_evidence() -> Poi {
Poi {
id: Uuid::new_v4(),
confidence: 0.92,
mgrs: "33UWP05".to_string(),
class: "tank".to_string(),
class_group: "vehicle".to_string(),
source_detection_ids: vec![Uuid::new_v4()],
enqueued_at: Utc::now(),
priority: 0.92,
decline_suppressed: false,
vlm_status: VlmPipelineStatus::Ok,
tier2_evidence: Some(Tier2Evidence {
roi_id: Uuid::new_v4(),
path_freshness: Some(0.7),
endpoint_score: Some(0.5),
concealment_score: Some(0.3),
recommended_next_action: RecommendedNextAction::HoldEndpoint,
source_detections: vec![],
status: Tier2Status::Ok,
}),
deadline: Utc::now() + Duration::seconds(120),
}
}
fn poi_vlm_disabled() -> Poi {
Poi {
vlm_status: VlmPipelineStatus::Disabled,
tier2_evidence: None,
..poi_with_full_evidence()
}
}
/// AC-1 — full POI maps with every required field populated; the
/// optional `tier2_evidence_summary` is present when input has it.
#[test]
fn ac1_full_poi_maps_all_required_fields() {
// Arrange
let poi = poi_with_full_evidence();
let meta = PhotoMetadata {
photo_ref: "snap/123.jpg".to_string(),
width: 1920,
height: 1080,
captured_at_unix_ms: 1_700_000_000_000,
};
// Act
let evt = PoiSurfaceMapper::map(&poi, Some(meta.clone()));
// Assert
assert_eq!(evt.poi_id, poi.id);
assert_eq!(evt.mgrs, "33UWP05");
assert_eq!(evt.class_group, "vehicle");
assert!((evt.confidence - 0.92).abs() < 1e-6);
assert_eq!(evt.vlm_status, VlmPipelineStatus::Ok);
let tier2 = evt
.tier2_evidence_summary
.as_ref()
.expect("Tier2 evidence should be carried through");
assert_eq!(
tier2.recommended_next_action,
RecommendedNextAction::HoldEndpoint
);
assert_eq!(tier2.status, Tier2Status::Ok);
assert_eq!(
evt.photo_metadata.as_ref().map(|p| &p.photo_ref),
Some(&meta.photo_ref)
);
assert_eq!(evt.deadline_unix_ms, poi.deadline.timestamp_millis());
}
/// AC-2 — VLM-disabled POIs map to vlm_status = Disabled and
/// vlm_label = None.
#[test]
fn ac2_vlm_disabled_carries_explicit_status() {
// Arrange
let poi = poi_vlm_disabled();
// Act
let evt = PoiSurfaceMapper::map(&poi, None);
// Assert
assert_eq!(evt.vlm_status, VlmPipelineStatus::Disabled);
assert!(evt.vlm_label.is_none());
// tier2 absence preserved.
assert!(evt.tier2_evidence_summary.is_none());
assert!(evt.photo_metadata.is_none());
}
/// AC-3 — Dequeue path emits a PoiDequeued event with the
/// configured reason and the supplied poi_id.
#[tokio::test]
async fn ac3_dequeue_emits_event_through_sink() {
// Arrange
let sink = RecordingSink::default();
let captured = Arc::clone(&sink.events);
let mapper = PoiSurfaceMapper::new(Arc::new(sink));
let poi = poi_with_full_evidence();
// Act — surface, then dequeue.
mapper.surface(&poi, None).await.unwrap();
mapper
.emit_dequeued(poi.id, DequeueReason::Rotated)
.await
.unwrap();
// Assert — sink saw both events in order.
let events = captured.lock().clone();
assert_eq!(events.len(), 2);
assert!(matches!(events[0], OperatorEvent::PoiSurfaced(_)));
match &events[1] {
OperatorEvent::PoiDequeued(d) => {
assert_eq!(d.poi_id, poi.id);
assert_eq!(d.reason, DequeueReason::Rotated);
}
_ => panic!("second event must be PoiDequeued"),
}
let m = mapper.metrics();
assert_eq!(m.pois_surfaced_total, 1);
assert_eq!(m.pois_dequeued_total, 1);
assert_eq!(m.pois_surfaced_per_min, 1);
}
}
+125 -10
View File
@@ -1,22 +1,38 @@
//! `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-678 `operator_bridge_command_auth`
//! - AZ-679 `operator_bridge_poi_surface`
//! - 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;
use shared::contracts::{OperatorCommandSink, TelemetrySink};
use shared::error::{AutopilotError, Result};
use shared::health::ComponentHealth;
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)]
@@ -46,6 +62,15 @@ pub struct OperatorBridge {
target_follow_tx: mpsc::Sender<TargetFollowEvent>,
middle_waypoint_rx: Option<mpsc::Receiver<MiddleWaypointHint>>,
target_follow_rx: Option<mpsc::Receiver<TargetFollowEvent>>,
/// 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<Arc<PoiSurfaceMapper>>,
/// 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<Arc<HmacOperatorValidator>>,
}
impl OperatorBridge {
@@ -57,13 +82,27 @@ impl OperatorBridge {
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<dyn TelemetrySink>) -> Self {
self.poi_mapper = Some(Arc::new(PoiSurfaceMapper::new(sink)));
self
}
pub fn with_validator(mut self, validator: Arc<HmacOperatorValidator>) -> 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(),
}
}
@@ -82,17 +121,79 @@ pub struct OperatorBridgeHandle {
middle_waypoint_tx: mpsc::Sender<MiddleWaypointHint>,
#[allow(dead_code)]
target_follow_tx: mpsc::Sender<TargetFollowEvent>,
poi_mapper: Option<Arc<PoiSurfaceMapper>>,
validator: Option<Arc<HmacOperatorValidator>>,
}
impl OperatorBridgeHandle {
pub async fn surface_poi(&self, _poi: Poi) -> Result<OperatorDecision> {
Err(AutopilotError::NotImplemented(
"operator_bridge::surface_poi (AZ-679)",
))
/// 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<OperatorDecision> {
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<PoiSurfaceMetrics> {
self.poi_mapper.as_ref().map(|m| m.metrics())
}
pub fn health(&self) -> ComponentHealth {
ComponentHealth::disabled(NAME)
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
}
}
@@ -110,8 +211,22 @@ mod tests {
use super::*;
#[test]
fn it_compiles() {
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"));
}
}