mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 18:21:10 +00:00
[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:
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user