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 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
425 let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
426 format!(
427 "00000000-0000-4000-8000-{:012x}",
428 id & 0x0000_ffff_ffff_ffff,
429 )
430}
431
432#[cfg(test)]
437mod tests {
438 use super::*;
439 use crate::types::id::{ContentHash, ObservationId};
440 use crate::types::observation::CaptureContext;
441 use crate::types::proposal::{ObservationProvenance, ProposedContent};
442
443 fn make_provenance() -> ObservationProvenance {
444 ObservationProvenance::new(
445 ObservationId::new("obs-test"),
446 ContentHash::zero(),
447 CaptureContext::default(),
448 )
449 }
450
451 fn make_draft(kind: ProposedContentKind, confidence: Option<f32>) -> Proposal<Draft> {
452 let mut content = ProposedContent::new(kind, "Test proposal content");
453 if let Some(c) = confidence {
454 content = content.with_confidence(c);
455 }
456 Proposal::new(ProposalId::new("test-proposal"), content, make_provenance())
457 }
458
459 #[test]
460 fn policy_gates_by_kind() {
461 let policy = HitlPolicy::for_kinds(vec![ProposedContentKind::Plan]);
462 let plan = make_draft(ProposedContentKind::Plan, Some(0.95));
463 let claim = make_draft(ProposedContentKind::Claim, Some(0.95));
464
465 assert!(policy.requires_approval(&plan, "agent-1"));
466 assert!(!policy.requires_approval(&claim, "agent-1"));
467 }
468
469 #[test]
470 fn policy_gates_by_confidence() {
471 let policy = HitlPolicy {
472 gated_kinds: Vec::new(),
473 confidence_threshold: Some(0.8),
474 gated_agent_ids: Vec::new(),
475 timeout: TimeoutPolicy::default(),
476 };
477 let low = make_draft(ProposedContentKind::Claim, Some(0.5));
478 let high = make_draft(ProposedContentKind::Claim, Some(0.9));
479 let none = make_draft(ProposedContentKind::Claim, None);
480
481 assert!(policy.requires_approval(&low, "agent-1"));
482 assert!(!policy.requires_approval(&high, "agent-1"));
483 assert!(policy.requires_approval(&none, "agent-1")); }
485
486 #[test]
487 fn policy_gates_by_agent() {
488 let policy = HitlPolicy {
489 gated_kinds: Vec::new(),
490 confidence_threshold: None,
491 gated_agent_ids: vec!["risky-agent".to_string()],
492 timeout: TimeoutPolicy::default(),
493 };
494 let proposal = make_draft(ProposedContentKind::Claim, Some(0.99));
495
496 assert!(policy.requires_approval(&proposal, "risky-agent"));
497 assert!(!policy.requires_approval(&proposal, "safe-agent"));
498 }
499
500 #[test]
501 fn gate_all_catches_everything() {
502 let policy = HitlPolicy::gate_all();
503 let proposal = make_draft(ProposedContentKind::Claim, Some(0.99));
504 assert!(policy.requires_approval(&proposal, "any-agent"));
506 }
507
508 #[test]
509 fn no_conditions_means_no_gating() {
510 let policy = HitlPolicy {
511 gated_kinds: Vec::new(),
512 confidence_threshold: None,
513 gated_agent_ids: Vec::new(),
514 timeout: TimeoutPolicy::default(),
515 };
516 let proposal = make_draft(ProposedContentKind::Claim, Some(0.5));
517 assert!(!policy.requires_approval(&proposal, "agent-1"));
518 }
519
520 #[test]
521 fn gate_decision_approve() {
522 let decision = GateDecision::approve(GateId::new("hitl-123"), "user@example.com");
523 assert!(decision.is_approved());
524 }
525
526 #[test]
527 fn gate_decision_reject_with_reason() {
528 let decision = GateDecision::reject(
529 GateId::new("hitl-123"),
530 "user@example.com",
531 Some("Proposal is too aggressive".to_string()),
532 );
533 assert!(!decision.is_approved());
534 if let GateVerdict::Reject { reason } = &decision.verdict {
535 assert_eq!(reason.as_deref(), Some("Proposal is too aggressive"));
536 } else {
537 panic!("Expected Reject verdict");
538 }
539 }
540
541 #[test]
542 fn gate_event_from_approval() {
543 let decision = GateDecision::approve(GateId::new("hitl-123"), "admin");
544 let event = GateEvent::from_decision(&decision);
545 assert!(matches!(event.kind, GateEventKind::Approved { .. }));
546 }
547
548 #[test]
549 fn gate_event_from_rejection() {
550 let decision = GateDecision::reject(GateId::new("hitl-123"), "admin", None);
551 let event = GateEvent::from_decision(&decision);
552 assert!(matches!(event.kind, GateEventKind::Rejected { .. }));
553 }
554
555 #[test]
556 fn gate_event_timed_out() {
557 let event = GateEvent::timed_out(GateId::new("hitl-123"), TimeoutAction::Reject);
558 assert!(matches!(
559 event.kind,
560 GateEventKind::TimedOut {
561 action_taken: TimeoutAction::Reject
562 }
563 ));
564 }
565
566 #[test]
567 fn timeout_policy_default() {
568 let policy = TimeoutPolicy::default();
569 assert_eq!(policy.timeout_secs, 30 * 60);
570 assert_eq!(policy.duration(), Duration::from_mins(30));
571 assert_eq!(policy.action, TimeoutAction::Reject);
572 }
573
574 #[test]
575 fn context_item_creation() {
576 let item = ContextItem::new("Revenue Impact", "$50,000 pipeline value");
577 assert_eq!(item.label, "Revenue Impact");
578 assert_eq!(item.value, "$50,000 pipeline value");
579 }
580
581 #[test]
582 fn gate_request_serde_roundtrip() {
583 let request = GateRequest {
584 gate_id: GateId::new("hitl-test"),
585 proposal_id: ProposalId::new("prop-1"),
586 summary: "Recommend premium tier for Acme Corp".to_string(),
587 agent_id: "pricing-agent".to_string(),
588 rationale: Some("High engagement signals".to_string()),
589 context_data: vec![ContextItem::new("ARR", "$120k")],
590 cycle: 3,
591 requested_at: Timestamp::now(),
592 timeout: TimeoutPolicy::default(),
593 };
594
595 let json = serde_json::to_string(&request).expect("serialize");
596 let back: GateRequest = serde_json::from_str(&json).expect("deserialize");
597 assert_eq!(back.gate_id.as_str(), "hitl-test");
598 assert_eq!(back.agent_id, "pricing-agent");
599 assert_eq!(back.cycle, 3);
600 }
601
602 #[test]
603 fn gate_decision_serde_roundtrip() {
604 let decisions = vec![
605 GateDecision::approve(GateId::new("hitl-1"), "user"),
606 GateDecision::reject(GateId::new("hitl-2"), "admin", Some("too risky".into())),
607 GateDecision::reject(GateId::new("hitl-3"), "admin", None),
608 ];
609
610 for decision in decisions {
611 let json = serde_json::to_string(&decision).expect("serialize");
612 let back: GateDecision = serde_json::from_str(&json).expect("deserialize");
613 assert_eq!(back.gate_id.as_str(), decision.gate_id.as_str());
614 assert_eq!(back.is_approved(), decision.is_approved());
615 }
616 }
617}