[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
+8
View File
@@ -4,12 +4,15 @@
//! importing the receiving crate. The composition root in
//! `crates/autopilot/src/runtime.rs` wires concrete implementations.
pub mod operator_auth;
use async_trait::async_trait;
use crate::error::Result;
use crate::models::detection::DetectionBatch;
use crate::models::frame::Frame;
use crate::models::operator::OperatorCommand;
use crate::models::operator_event::OperatorEvent;
use crate::models::vlm::VlmAssessment;
/// Telemetry uplink. Implemented by `telemetry_stream`, consumed by
@@ -19,6 +22,11 @@ use crate::models::vlm::VlmAssessment;
pub trait TelemetrySink: Send + Sync {
async fn push_frame(&self, frame: Frame) -> Result<()>;
async fn push_detections(&self, batch: DetectionBatch) -> Result<()>;
/// AZ-679 — push a POI surface event (or its dequeue event) to
/// the operator. The receiving impl serialises onto the
/// appropriate operator-bound topic.
async fn push_operator_event(&self, event: OperatorEvent) -> Result<()>;
}
/// MAVLink command surface. Implemented by `mavlink_layer`, consumed by
@@ -0,0 +1,99 @@
//! AZ-678 — operator command authentication contract.
//!
//! `OperatorCommandValidator` is the boundary every operator-bound
//! command crosses before any business logic runs. The default
//! implementation (`HmacOperatorValidator` in
//! `operator_bridge::internal::auth`) uses HMAC-SHA256 over
//! `(session_token || sequence_number || payload_bytes)`. The trait
//! lives here so the dispatch surface (`scan_controller`,
//! `mission_executor`) can depend on the contract without importing
//! `operator_bridge`.
use thiserror::Error;
use crate::models::operator::{OperatorCommand, OperatorCommandKind};
/// A command as it arrives over the operator-link, prior to any
/// authentication. Mirrors the validated `OperatorCommand` shape
/// closely so a successful `validate` is a near-identity transform.
#[derive(Debug, Clone)]
pub struct SignedCommand {
pub session_token: String,
pub sequence_number: u64,
pub kind: OperatorCommandKind,
pub payload: serde_json::Value,
/// HMAC over `(session_token || sequence_number || canonical
/// JSON of payload)`. Length depends on the scheme; for HMAC-SHA256
/// this is exactly 32 bytes.
pub signature: Vec<u8>,
/// Wall-clock time the Ground Station stamped the command. Carried
/// through `validate` for downstream audit logging; not used by
/// the auth check itself.
pub issued_at_wallclock: chrono::DateTime<chrono::Utc>,
pub command_id: uuid::Uuid,
}
impl SignedCommand {
/// Convert into a canonical [`OperatorCommand`] once validation
/// has succeeded. The signature is retained on the result for
/// downstream audit logging.
pub fn into_command(self) -> OperatorCommand {
OperatorCommand {
command_id: self.command_id,
session_token: self.session_token,
sequence_number: self.sequence_number,
issued_at_wallclock: self.issued_at_wallclock,
kind: self.kind,
payload: self.payload,
signature: self.signature,
}
}
}
/// Validated command. Returned by `OperatorCommandValidator::validate`
/// on the happy path. Holding a `ValidatedCommand` is the proof that
/// dispatching the inner command is safe.
#[derive(Debug, Clone)]
pub struct ValidatedCommand {
pub command: OperatorCommand,
}
/// Why an operator command was rejected. Each variant maps 1-1 to a
/// `auth_rejections_total{reason}` metric counter and to a structured
/// log line. Order MUST match
/// `operator_bridge::internal::auth::REJECTION_REASONS` for the
/// counter array layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum AuthError {
#[error("signature does not match computed HMAC")]
SignatureInvalid,
#[error("replay detected — sequence number not greater than last seen")]
ReplayDetected,
#[error("session token unknown or never established")]
SessionUnknown,
#[error("session token expired (TTL elapsed)")]
SessionExpired,
}
impl AuthError {
/// Stable kebab-case label for the rejection-reason metric.
pub fn reason_label(&self) -> &'static str {
match self {
Self::SignatureInvalid => "signature_invalid",
Self::ReplayDetected => "replay_detected",
Self::SessionUnknown => "session_unknown",
Self::SessionExpired => "session_expired",
}
}
}
/// Contract every operator-command validator must satisfy. The
/// default `HmacOperatorValidator` lives in `operator_bridge`; other
/// schemes (e.g. Q9 resolution to a JWS-based one) implement the
/// same trait and can be swapped behind the same callsite.
pub trait OperatorCommandValidator: Send + Sync {
/// Validate one signed command. On `Ok`, the per-session
/// sequence-number tracker advances; on `Err`, it does NOT
/// advance (so the rejected `seq` does not poison the session).
fn validate(&self, cmd: SignedCommand) -> Result<ValidatedCommand, AuthError>;
}
+1
View File
@@ -10,6 +10,7 @@ pub mod mapobject;
pub mod mission;
pub mod movement;
pub mod operator;
pub mod operator_event;
pub mod poi;
pub mod telemetry;
pub mod tier2;
+144
View File
@@ -0,0 +1,144 @@
//! AZ-679 — operator-bound POI surface events.
//!
//! Wire shape that `operator_bridge` produces from a `Poi` and pushes
//! through `telemetry_stream` to the Ground Station. Fields follow
//! `architecture.md §7.10 Drone ⇄ Operator Sync Message Format` and
//! the AZ-679 task spec.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::poi::VlmPipelineStatus;
use super::tier2::{RecommendedNextAction, Tier2Status};
/// Tier-2 evidence summary as carried to the operator. We do not
/// expose internal ROI identifiers or source-detection UUIDs — the
/// operator only needs the scored summary and the recommended next
/// action.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Tier2EvidenceSummary {
#[serde(skip_serializing_if = "Option::is_none")]
pub path_freshness: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint_score: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub concealment_score: Option<f32>,
pub recommended_next_action: RecommendedNextAction,
pub status: Tier2Status,
}
/// Photo metadata carried with every POI per `architecture.md §7.10`.
/// Optional because some POIs (e.g. movement-only with no ROI crop)
/// may not have a photo yet.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PhotoMetadata {
pub photo_ref: String,
pub width: u32,
pub height: u32,
pub captured_at_unix_ms: i64,
}
/// Wire-format POI surface message — what the operator's UI consumes.
///
/// `vlm_label` is `Some` only when `vlm_status == Ok`. For
/// `Disabled` / `NotRequested` etc. the operator receives the status
/// alone and renders accordingly (AC-2 in the task spec).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperatorPoiEvent {
pub poi_id: Uuid,
pub mgrs: String,
pub class_group: String,
pub confidence: f32,
pub vlm_status: VlmPipelineStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub vlm_label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tier2_evidence_summary: Option<Tier2EvidenceSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub photo_metadata: Option<PhotoMetadata>,
pub deadline_unix_ms: i64,
}
/// Why a POI was removed from the surfaced queue. Operator UIs use
/// this to distinguish "operator hit deadline" from "queue rotated
/// to make room for a higher-confidence POI".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DequeueReason {
/// Decision-window deadline elapsed without operator input.
Aged,
/// Operator decided (confirmed / declined / target-follow).
Completed,
/// Queue rotated (higher-confidence or higher-priority POI took
/// the slot).
Rotated,
}
/// Emitted by `operator_bridge` whenever a previously-surfaced POI
/// leaves the queue.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoiDequeued {
pub poi_id: Uuid,
pub reason: DequeueReason,
pub dequeued_at: DateTime<Utc>,
}
/// Tagged enum the composition root pushes through
/// `TelemetrySink::push_operator_event`. The discriminator on the
/// wire is `"kind": "poi_surfaced" | "poi_dequeued"`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OperatorEvent {
PoiSurfaced(OperatorPoiEvent),
PoiDequeued(PoiDequeued),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operator_event_serde_roundtrip_poi_surfaced() {
// Arrange
let evt = OperatorEvent::PoiSurfaced(OperatorPoiEvent {
poi_id: Uuid::nil(),
mgrs: "33UWP01".to_string(),
class_group: "vehicle".to_string(),
confidence: 0.82,
vlm_status: VlmPipelineStatus::Disabled,
vlm_label: None,
tier2_evidence_summary: None,
photo_metadata: None,
deadline_unix_ms: 1_700_000_000_000,
});
// Act
let s = serde_json::to_string(&evt).unwrap();
let back: OperatorEvent = serde_json::from_str(&s).unwrap();
// Assert
assert!(matches!(back, OperatorEvent::PoiSurfaced(_)));
assert!(s.contains("\"kind\":\"poi_surfaced\""));
assert!(s.contains("\"vlm_status\":\"disabled\""));
}
#[test]
fn operator_event_serde_roundtrip_dequeued() {
// Arrange
let evt = OperatorEvent::PoiDequeued(PoiDequeued {
poi_id: Uuid::nil(),
reason: DequeueReason::Aged,
dequeued_at: Utc::now(),
});
// Act
let s = serde_json::to_string(&evt).unwrap();
let back: OperatorEvent = serde_json::from_str(&s).unwrap();
// Assert
assert!(matches!(back, OperatorEvent::PoiDequeued(_)));
assert!(s.contains("\"kind\":\"poi_dequeued\""));
assert!(s.contains("\"reason\":\"aged\""));
}
}