1use serde::{Deserialize, Serialize};
30use std::time::Duration;
31
32use crate::types::id::{GateId, ProposalId, Timestamp};
33use crate::types::proposal::{Draft, Proposal, ProposedContentKind};
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct HitlPolicy {
45 pub gated_kinds: Vec<ProposedContentKind>,
48
49 pub confidence_threshold: Option<f32>,
52
53 pub gated_agent_ids: Vec<String>,
56
57 pub timeout: TimeoutPolicy,
59}
60
61impl HitlPolicy {
62 pub fn gate_all() -> Self {
64 Self {
65 gated_kinds: Vec::new(),
66 confidence_threshold: Some(1.0), gated_agent_ids: Vec::new(),
68 timeout: TimeoutPolicy::default(),
69 }
70 }
71
72 pub fn for_kinds(kinds: Vec<ProposedContentKind>) -> Self {
74 Self {
75 gated_kinds: kinds,
76 confidence_threshold: None,
77 gated_agent_ids: Vec::new(),
78 timeout: TimeoutPolicy::default(),
79 }
80 }
81
82 pub fn requires_approval(&self, proposal: &Proposal<Draft>, agent_id: &str) -> bool {
84 if !self.gated_kinds.is_empty() && self.gated_kinds.contains(&proposal.content().kind) {
86 return true;
87 }
88
89 if let Some(threshold) = self.confidence_threshold {
91 if let Some(confidence) = proposal.content().confidence() {
92 if confidence < threshold {
93 return true;
94 }
95 } else {
96 return true;
98 }
99 }
100
101 if self.gated_agent_ids.contains(&agent_id.to_string()) {
103 return true;
104 }
105
106 false
107 }
108
109 pub fn with_timeout(mut self, timeout: TimeoutPolicy) -> Self {
111 self.timeout = timeout;
112 self
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct TimeoutPolicy {
123 pub timeout_secs: u64,
125
126 pub action: TimeoutAction,
128}
129
130impl TimeoutPolicy {
131 pub fn duration(&self) -> Duration {
133 Duration::from_secs(self.timeout_secs)
134 }
135}
136
137impl Default for TimeoutPolicy {
138 fn default() -> Self {
139 Self {
140 timeout_secs: 30 * 60, action: TimeoutAction::Reject,
142 }
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148pub enum TimeoutAction {
149 Reject,
151 Approve,
153 Escalate,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct GateRequest {
167 pub gate_id: GateId,
169
170 pub proposal_id: ProposalId,
172
173 pub summary: String,
175
176 pub agent_id: String,
178
179 pub rationale: Option<String>,
181
182 pub context_data: Vec<ContextItem>,
184
185 pub cycle: u32,
187
188 pub requested_at: Timestamp,
190
191 pub timeout: TimeoutPolicy,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ContextItem {
198 pub label: String,
200 pub value: String,
202}
203
204impl ContextItem {
205 pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
207 Self {
208 label: label.into(),
209 value: value.into(),
210 }
211 }
212}
213
214impl GateRequest {
215 #[allow(dead_code)]
217 pub(crate) fn new(
218 proposal: &Proposal<Draft>,
219 agent_id: impl Into<String>,
220 cycle: u32,
221 timeout: TimeoutPolicy,
222 ) -> Self {
223 Self {
224 gate_id: GateId::new(format!("hitl-{}", pseudo_uuid())),
225 proposal_id: proposal.id().clone(),
226 summary: proposal.content().content.clone(),
227 agent_id: agent_id.into(),
228 rationale: None,
229 context_data: Vec::new(),
230 cycle,
231 requested_at: Timestamp::now(),
232 timeout,
233 }
234 }
235
236 pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
238 self.rationale = Some(rationale.into());
239 self
240 }
241
242 pub fn with_context(mut self, items: Vec<ContextItem>) -> Self {
244 self.context_data = items;
245 self
246 }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct GateDecision {
256 pub gate_id: GateId,
258
259 pub verdict: GateVerdict,
261
262 pub decided_by: String,
264
265 pub decided_at: Timestamp,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
271pub enum GateVerdict {
272 Approve,
274 Reject {
276 reason: Option<String>,
278 },
279}
280
281impl GateDecision {
282 pub fn approve(gate_id: GateId, decided_by: impl Into<String>) -> Self {
284 Self {
285 gate_id,
286 verdict: GateVerdict::Approve,
287 decided_by: decided_by.into(),
288 decided_at: Timestamp::now(),
289 }
290 }
291
292 pub fn reject(gate_id: GateId, decided_by: impl Into<String>, reason: Option<String>) -> Self {
294 Self {
295 gate_id,
296 verdict: GateVerdict::Reject { reason },
297 decided_by: decided_by.into(),
298 decided_at: Timestamp::now(),
299 }
300 }
301
302 pub fn is_approved(&self) -> bool {
304 matches!(self.verdict, GateVerdict::Approve)
305 }
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct GateEvent {
317 pub gate_id: GateId,
319 pub kind: GateEventKind,
321 pub timestamp: Timestamp,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
327pub enum GateEventKind {
328 Requested {
330 proposal_id: ProposalId,
332 agent_id: String,
334 },
335 Approved {
337 decided_by: String,
339 },
340 Rejected {
342 decided_by: String,
344 reason: Option<String>,
346 },
347 TimedOut {
349 action_taken: TimeoutAction,
351 },
352}
353
354impl GateEvent {
355 pub fn requested(gate_id: GateId, proposal_id: ProposalId, agent_id: String) -> Self {
357 Self {
358 gate_id,
359 kind: GateEventKind::Requested {
360 proposal_id,
361 agent_id,
362 },
363 timestamp: Timestamp::now(),
364 }
365 }
366
367 pub fn from_decision(decision: &GateDecision) -> Self {
369 let kind = match &decision.verdict {
370 GateVerdict::Approve => GateEventKind::Approved {
371 decided_by: decision.decided_by.clone(),
372 },
373 GateVerdict::Reject { reason } => GateEventKind::Rejected {
374 decided_by: decision.decided_by.clone(),
375 reason: reason.clone(),
376 },
377 };
378 Self {
379 gate_id: decision.gate_id.clone(),
380 kind,
381 timestamp: decision.decided_at.clone(),
382 }
383 }
384
385 pub fn timed_out(gate_id: GateId, action_taken: TimeoutAction) -> Self {
387 Self {
388 gate_id,
389 kind: GateEventKind::TimedOut { action_taken },
390 timestamp: Timestamp::now(),
391 }
392 }
393}
394
395#[derive(Debug, Clone)]
404#[allow(dead_code)]
405pub(crate) struct PendingGate {
406 pub request: GateRequest,
408 pub proposal: Proposal<Draft>,
410 pub agent_id: String,
412 pub paused_at_cycle: u32,
414}
415
416#[allow(dead_code)]
423fn pseudo_uuid() -> String {
424 use std::time::{SystemTime, UNIX_EPOCH};
425
426 let now = SystemTime::now()
427 .duration_since(UNIX_EPOCH)
428 .unwrap_or_default();
429 let nanos = now.as_nanos();
430 format!(
431 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
432 (nanos >> 96) as u32,
433 (nanos >> 80) as u16,
434 (nanos >> 64) as u16 & 0x0fff,
435 ((nanos >> 48) as u16 & 0x3fff) | 0x8000,
436 nanos as u64 & 0xffffffffffff,
437 )
438}
439
440#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::types::id::{ContentHash, ObservationId};
448 use crate::types::observation::CaptureContext;
449 use crate::types::proposal::{ObservationProvenance, ProposedContent};
450
451 fn make_provenance() -> ObservationProvenance {
452 ObservationProvenance::new(
453 ObservationId::new("obs-test"),
454 ContentHash::zero(),
455 CaptureContext::default(),
456 )
457 }
458
459 fn make_draft(kind: ProposedContentKind, confidence: Option<f32>) -> Proposal<Draft> {
460 let mut content = ProposedContent::new(kind, "Test proposal content");
461 if let Some(c) = confidence {
462 content = content.with_confidence(c);
463 }
464 Proposal::new(ProposalId::new("test-proposal"), content, make_provenance())
465 }
466
467 #[test]
468 fn policy_gates_by_kind() {
469 let policy = HitlPolicy::for_kinds(vec![ProposedContentKind::Plan]);
470 let plan = make_draft(ProposedContentKind::Plan, Some(0.95));
471 let claim = make_draft(ProposedContentKind::Claim, Some(0.95));
472
473 assert!(policy.requires_approval(&plan, "agent-1"));
474 assert!(!policy.requires_approval(&claim, "agent-1"));
475 }
476
477 #[test]
478 fn policy_gates_by_confidence() {
479 let policy = HitlPolicy {
480 gated_kinds: Vec::new(),
481 confidence_threshold: Some(0.8),
482 gated_agent_ids: Vec::new(),
483 timeout: TimeoutPolicy::default(),
484 };
485 let low = make_draft(ProposedContentKind::Claim, Some(0.5));
486 let high = make_draft(ProposedContentKind::Claim, Some(0.9));
487 let none = make_draft(ProposedContentKind::Claim, None);
488
489 assert!(policy.requires_approval(&low, "agent-1"));
490 assert!(!policy.requires_approval(&high, "agent-1"));
491 assert!(policy.requires_approval(&none, "agent-1")); }
493
494 #[test]
495 fn policy_gates_by_agent() {
496 let policy = HitlPolicy {
497 gated_kinds: Vec::new(),
498 confidence_threshold: None,
499 gated_agent_ids: vec!["risky-agent".to_string()],
500 timeout: TimeoutPolicy::default(),
501 };
502 let proposal = make_draft(ProposedContentKind::Claim, Some(0.99));
503
504 assert!(policy.requires_approval(&proposal, "risky-agent"));
505 assert!(!policy.requires_approval(&proposal, "safe-agent"));
506 }
507
508 #[test]
509 fn gate_all_catches_everything() {
510 let policy = HitlPolicy::gate_all();
511 let proposal = make_draft(ProposedContentKind::Claim, Some(0.99));
512 assert!(policy.requires_approval(&proposal, "any-agent"));
514 }
515
516 #[test]
517 fn no_conditions_means_no_gating() {
518 let policy = HitlPolicy {
519 gated_kinds: Vec::new(),
520 confidence_threshold: None,
521 gated_agent_ids: Vec::new(),
522 timeout: TimeoutPolicy::default(),
523 };
524 let proposal = make_draft(ProposedContentKind::Claim, Some(0.5));
525 assert!(!policy.requires_approval(&proposal, "agent-1"));
526 }
527
528 #[test]
529 fn gate_decision_approve() {
530 let decision = GateDecision::approve(GateId::new("hitl-123"), "user@example.com");
531 assert!(decision.is_approved());
532 }
533
534 #[test]
535 fn gate_decision_reject_with_reason() {
536 let decision = GateDecision::reject(
537 GateId::new("hitl-123"),
538 "user@example.com",
539 Some("Proposal is too aggressive".to_string()),
540 );
541 assert!(!decision.is_approved());
542 if let GateVerdict::Reject { reason } = &decision.verdict {
543 assert_eq!(reason.as_deref(), Some("Proposal is too aggressive"));
544 } else {
545 panic!("Expected Reject verdict");
546 }
547 }
548
549 #[test]
550 fn gate_event_from_approval() {
551 let decision = GateDecision::approve(GateId::new("hitl-123"), "admin");
552 let event = GateEvent::from_decision(&decision);
553 assert!(matches!(event.kind, GateEventKind::Approved { .. }));
554 }
555
556 #[test]
557 fn gate_event_from_rejection() {
558 let decision = GateDecision::reject(GateId::new("hitl-123"), "admin", None);
559 let event = GateEvent::from_decision(&decision);
560 assert!(matches!(event.kind, GateEventKind::Rejected { .. }));
561 }
562
563 #[test]
564 fn gate_event_timed_out() {
565 let event = GateEvent::timed_out(GateId::new("hitl-123"), TimeoutAction::Reject);
566 assert!(matches!(
567 event.kind,
568 GateEventKind::TimedOut {
569 action_taken: TimeoutAction::Reject
570 }
571 ));
572 }
573
574 #[test]
575 fn timeout_policy_default() {
576 let policy = TimeoutPolicy::default();
577 assert_eq!(policy.timeout_secs, 30 * 60);
578 assert_eq!(policy.duration(), Duration::from_secs(1800));
579 assert_eq!(policy.action, TimeoutAction::Reject);
580 }
581
582 #[test]
583 fn context_item_creation() {
584 let item = ContextItem::new("Revenue Impact", "$50,000 pipeline value");
585 assert_eq!(item.label, "Revenue Impact");
586 assert_eq!(item.value, "$50,000 pipeline value");
587 }
588
589 #[test]
590 fn gate_request_serde_roundtrip() {
591 let request = GateRequest {
592 gate_id: GateId::new("hitl-test"),
593 proposal_id: ProposalId::new("prop-1"),
594 summary: "Recommend premium tier for Acme Corp".to_string(),
595 agent_id: "pricing-agent".to_string(),
596 rationale: Some("High engagement signals".to_string()),
597 context_data: vec![ContextItem::new("ARR", "$120k")],
598 cycle: 3,
599 requested_at: Timestamp::now(),
600 timeout: TimeoutPolicy::default(),
601 };
602
603 let json = serde_json::to_string(&request).expect("serialize");
604 let back: GateRequest = serde_json::from_str(&json).expect("deserialize");
605 assert_eq!(back.gate_id.as_str(), "hitl-test");
606 assert_eq!(back.agent_id, "pricing-agent");
607 assert_eq!(back.cycle, 3);
608 }
609
610 #[test]
611 fn gate_decision_serde_roundtrip() {
612 let decisions = vec![
613 GateDecision::approve(GateId::new("hitl-1"), "user"),
614 GateDecision::reject(GateId::new("hitl-2"), "admin", Some("too risky".into())),
615 GateDecision::reject(GateId::new("hitl-3"), "admin", None),
616 ];
617
618 for decision in decisions {
619 let json = serde_json::to_string(&decision).expect("serialize");
620 let back: GateDecision = serde_json::from_str(&json).expect("deserialize");
621 assert_eq!(back.gate_id.as_str(), decision.gate_id.as_str());
622 assert_eq!(back.is_approved(), decision.is_approved());
623 }
624 }
625}