use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::types::id::{GateId, ProposalId, Timestamp};
use crate::types::proposal::{Draft, Proposal, ProposedContentKind};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HitlPolicy {
pub gated_kinds: Vec<ProposedContentKind>,
pub confidence_threshold: Option<f32>,
pub gated_agent_ids: Vec<String>,
pub timeout: TimeoutPolicy,
}
impl HitlPolicy {
pub fn gate_all() -> Self {
Self {
gated_kinds: Vec::new(),
confidence_threshold: Some(1.0), gated_agent_ids: Vec::new(),
timeout: TimeoutPolicy::default(),
}
}
pub fn for_kinds(kinds: Vec<ProposedContentKind>) -> Self {
Self {
gated_kinds: kinds,
confidence_threshold: None,
gated_agent_ids: Vec::new(),
timeout: TimeoutPolicy::default(),
}
}
pub fn requires_approval(&self, proposal: &Proposal<Draft>, agent_id: &str) -> bool {
if !self.gated_kinds.is_empty() && self.gated_kinds.contains(&proposal.content().kind) {
return true;
}
if let Some(threshold) = self.confidence_threshold {
if let Some(confidence) = proposal.content().confidence {
if confidence < threshold {
return true;
}
} else {
return true;
}
}
if self.gated_agent_ids.contains(&agent_id.to_string()) {
return true;
}
false
}
pub fn with_timeout(mut self, timeout: TimeoutPolicy) -> Self {
self.timeout = timeout;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeoutPolicy {
pub timeout_secs: u64,
pub action: TimeoutAction,
}
impl TimeoutPolicy {
pub fn duration(&self) -> Duration {
Duration::from_secs(self.timeout_secs)
}
}
impl Default for TimeoutPolicy {
fn default() -> Self {
Self {
timeout_secs: 30 * 60, action: TimeoutAction::Reject,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TimeoutAction {
Reject,
Approve,
Escalate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateRequest {
pub gate_id: GateId,
pub proposal_id: ProposalId,
pub summary: String,
pub agent_id: String,
pub rationale: Option<String>,
pub context_data: Vec<ContextItem>,
pub cycle: u32,
pub requested_at: Timestamp,
pub timeout: TimeoutPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextItem {
pub label: String,
pub value: String,
}
impl ContextItem {
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
label: label.into(),
value: value.into(),
}
}
}
impl GateRequest {
pub(crate) fn new(
proposal: &Proposal<Draft>,
agent_id: impl Into<String>,
cycle: u32,
timeout: TimeoutPolicy,
) -> Self {
Self {
gate_id: GateId::new(format!("hitl-{}", pseudo_uuid())),
proposal_id: proposal.id().clone(),
summary: proposal.content().content.clone(),
agent_id: agent_id.into(),
rationale: None,
context_data: Vec::new(),
cycle,
requested_at: Timestamp::now(),
timeout,
}
}
pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
self.rationale = Some(rationale.into());
self
}
pub fn with_context(mut self, items: Vec<ContextItem>) -> Self {
self.context_data = items;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateDecision {
pub gate_id: GateId,
pub verdict: GateVerdict,
pub decided_by: String,
pub decided_at: Timestamp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GateVerdict {
Approve,
Reject {
reason: Option<String>,
},
}
impl GateDecision {
pub fn approve(gate_id: GateId, decided_by: impl Into<String>) -> Self {
Self {
gate_id,
verdict: GateVerdict::Approve,
decided_by: decided_by.into(),
decided_at: Timestamp::now(),
}
}
pub fn reject(gate_id: GateId, decided_by: impl Into<String>, reason: Option<String>) -> Self {
Self {
gate_id,
verdict: GateVerdict::Reject { reason },
decided_by: decided_by.into(),
decided_at: Timestamp::now(),
}
}
pub fn is_approved(&self) -> bool {
matches!(self.verdict, GateVerdict::Approve)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateEvent {
pub gate_id: GateId,
pub kind: GateEventKind,
pub timestamp: Timestamp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GateEventKind {
Requested {
proposal_id: ProposalId,
agent_id: String,
},
Approved {
decided_by: String,
},
Rejected {
decided_by: String,
reason: Option<String>,
},
TimedOut {
action_taken: TimeoutAction,
},
}
impl GateEvent {
pub fn requested(gate_id: GateId, proposal_id: ProposalId, agent_id: String) -> Self {
Self {
gate_id,
kind: GateEventKind::Requested {
proposal_id,
agent_id,
},
timestamp: Timestamp::now(),
}
}
pub fn from_decision(decision: &GateDecision) -> Self {
let kind = match &decision.verdict {
GateVerdict::Approve => GateEventKind::Approved {
decided_by: decision.decided_by.clone(),
},
GateVerdict::Reject { reason } => GateEventKind::Rejected {
decided_by: decision.decided_by.clone(),
reason: reason.clone(),
},
};
Self {
gate_id: decision.gate_id.clone(),
kind,
timestamp: decision.decided_at.clone(),
}
}
pub fn timed_out(gate_id: GateId, action_taken: TimeoutAction) -> Self {
Self {
gate_id,
kind: GateEventKind::TimedOut { action_taken },
timestamp: Timestamp::now(),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct PendingGate {
pub request: GateRequest,
pub proposal: Proposal<Draft>,
pub agent_id: String,
pub paused_at_cycle: u32,
}
fn pseudo_uuid() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let nanos = now.as_nanos();
format!(
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
(nanos >> 96) as u32,
(nanos >> 80) as u16,
(nanos >> 64) as u16 & 0x0fff,
((nanos >> 48) as u16 & 0x3fff) | 0x8000,
nanos as u64 & 0xffffffffffff,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::id::{ContentHash, ObservationId};
use crate::types::observation::CaptureContext;
use crate::types::proposal::{ObservationProvenance, ProposedContent};
fn make_provenance() -> ObservationProvenance {
ObservationProvenance::new(
ObservationId::new("obs-test"),
ContentHash::zero(),
CaptureContext::default(),
)
}
fn make_draft(kind: ProposedContentKind, confidence: Option<f32>) -> Proposal<Draft> {
let mut content = ProposedContent::new(kind, "Test proposal content");
if let Some(c) = confidence {
content = content.with_confidence(c);
}
Proposal::new(ProposalId::new("test-proposal"), content, make_provenance())
}
#[test]
fn policy_gates_by_kind() {
let policy = HitlPolicy::for_kinds(vec![ProposedContentKind::Plan]);
let plan = make_draft(ProposedContentKind::Plan, Some(0.95));
let claim = make_draft(ProposedContentKind::Claim, Some(0.95));
assert!(policy.requires_approval(&plan, "agent-1"));
assert!(!policy.requires_approval(&claim, "agent-1"));
}
#[test]
fn policy_gates_by_confidence() {
let policy = HitlPolicy {
gated_kinds: Vec::new(),
confidence_threshold: Some(0.8),
gated_agent_ids: Vec::new(),
timeout: TimeoutPolicy::default(),
};
let low = make_draft(ProposedContentKind::Claim, Some(0.5));
let high = make_draft(ProposedContentKind::Claim, Some(0.9));
let none = make_draft(ProposedContentKind::Claim, None);
assert!(policy.requires_approval(&low, "agent-1"));
assert!(!policy.requires_approval(&high, "agent-1"));
assert!(policy.requires_approval(&none, "agent-1")); }
#[test]
fn policy_gates_by_agent() {
let policy = HitlPolicy {
gated_kinds: Vec::new(),
confidence_threshold: None,
gated_agent_ids: vec!["risky-agent".to_string()],
timeout: TimeoutPolicy::default(),
};
let proposal = make_draft(ProposedContentKind::Claim, Some(0.99));
assert!(policy.requires_approval(&proposal, "risky-agent"));
assert!(!policy.requires_approval(&proposal, "safe-agent"));
}
#[test]
fn gate_all_catches_everything() {
let policy = HitlPolicy::gate_all();
let proposal = make_draft(ProposedContentKind::Claim, Some(0.99));
assert!(policy.requires_approval(&proposal, "any-agent"));
}
#[test]
fn no_conditions_means_no_gating() {
let policy = HitlPolicy {
gated_kinds: Vec::new(),
confidence_threshold: None,
gated_agent_ids: Vec::new(),
timeout: TimeoutPolicy::default(),
};
let proposal = make_draft(ProposedContentKind::Claim, Some(0.5));
assert!(!policy.requires_approval(&proposal, "agent-1"));
}
#[test]
fn gate_decision_approve() {
let decision = GateDecision::approve(GateId::new("hitl-123"), "user@example.com");
assert!(decision.is_approved());
}
#[test]
fn gate_decision_reject_with_reason() {
let decision = GateDecision::reject(
GateId::new("hitl-123"),
"user@example.com",
Some("Proposal is too aggressive".to_string()),
);
assert!(!decision.is_approved());
if let GateVerdict::Reject { reason } = &decision.verdict {
assert_eq!(reason.as_deref(), Some("Proposal is too aggressive"));
} else {
panic!("Expected Reject verdict");
}
}
#[test]
fn gate_event_from_approval() {
let decision = GateDecision::approve(GateId::new("hitl-123"), "admin");
let event = GateEvent::from_decision(&decision);
assert!(matches!(event.kind, GateEventKind::Approved { .. }));
}
#[test]
fn gate_event_from_rejection() {
let decision = GateDecision::reject(GateId::new("hitl-123"), "admin", None);
let event = GateEvent::from_decision(&decision);
assert!(matches!(event.kind, GateEventKind::Rejected { .. }));
}
#[test]
fn gate_event_timed_out() {
let event = GateEvent::timed_out(GateId::new("hitl-123"), TimeoutAction::Reject);
assert!(matches!(
event.kind,
GateEventKind::TimedOut {
action_taken: TimeoutAction::Reject
}
));
}
#[test]
fn timeout_policy_default() {
let policy = TimeoutPolicy::default();
assert_eq!(policy.timeout_secs, 30 * 60);
assert_eq!(policy.duration(), Duration::from_secs(1800));
assert_eq!(policy.action, TimeoutAction::Reject);
}
#[test]
fn context_item_creation() {
let item = ContextItem::new("Revenue Impact", "$50,000 pipeline value");
assert_eq!(item.label, "Revenue Impact");
assert_eq!(item.value, "$50,000 pipeline value");
}
#[test]
fn gate_request_serde_roundtrip() {
let request = GateRequest {
gate_id: GateId::new("hitl-test"),
proposal_id: ProposalId::new("prop-1"),
summary: "Recommend premium tier for Acme Corp".to_string(),
agent_id: "pricing-agent".to_string(),
rationale: Some("High engagement signals".to_string()),
context_data: vec![ContextItem::new("ARR", "$120k")],
cycle: 3,
requested_at: Timestamp::now(),
timeout: TimeoutPolicy::default(),
};
let json = serde_json::to_string(&request).expect("serialize");
let back: GateRequest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.gate_id.as_str(), "hitl-test");
assert_eq!(back.agent_id, "pricing-agent");
assert_eq!(back.cycle, 3);
}
#[test]
fn gate_decision_serde_roundtrip() {
let decisions = vec![
GateDecision::approve(GateId::new("hitl-1"), "user"),
GateDecision::reject(GateId::new("hitl-2"), "admin", Some("too risky".into())),
GateDecision::reject(GateId::new("hitl-3"), "admin", None),
];
for decision in decisions {
let json = serde_json::to_string(&decision).expect("serialize");
let back: GateDecision = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.gate_id.as_str(), decision.gate_id.as_str());
assert_eq!(back.is_approved(), decision.is_approved());
}
}
}