Skip to main content

converge_core/gates/
hitl.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Human-in-the-Loop (HITL) gate for convergence pause/resume.
5//!
6//! When a proposal matches a HITL policy, the engine pauses convergence
7//! and emits a [`GateRequest`]. The hosting application notifies a human
8//! (Slack, email, UI — not our concern). The human approves or rejects.
9//! The application calls [`Engine::resume`] with a [`GateDecision`].
10//!
11//! # Separation of Concerns
12//!
13//! | Layer | Responsibility |
14//! |-------|---------------|
15//! | **converge-core** (this module) | Gate types, pause/resume, audit, policy |
16//! | **Application layer** | Webhook dispatch, REST endpoints, signed tokens, timeouts |
17//!
18//! converge-core does NOT know about Slack, email, or any notification channel.
19//!
20//! # Design Tenets
21//!
22//! - **Human Authority First-Class**: HITL gates make human approval an explicit,
23//!   typed step in the convergence lifecycle.
24//! - **Explicit Authority**: `GateDecision::Approve` creates an `AuthorityGrant::human()`.
25//!   No auto-approval unless the policy explicitly allows timeout escalation.
26//! - **No Hidden Work**: Every gate event (pause, approve, reject, timeout)
27//!   is recorded in the audit trail via [`GateEvent`].
28
29use serde::{Deserialize, Serialize};
30use std::time::Duration;
31
32use crate::types::id::{GateId, ProposalId, Timestamp};
33use crate::types::proposal::{Draft, Proposal, ProposedContentKind};
34
35// ============================================================================
36// HitlPolicy — when does human approval apply?
37// ============================================================================
38
39/// Policy controlling when HITL approval is required.
40///
41/// Can be configured per-workspace or per-agent. A proposal triggers
42/// HITL if ANY of the configured conditions match.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct HitlPolicy {
45    /// Which proposal content kinds require HITL approval.
46    /// Empty means no kind-based gating.
47    pub gated_kinds: Vec<ProposedContentKind>,
48
49    /// Confidence threshold: proposals below this trigger HITL.
50    /// `None` means no confidence-based gating.
51    pub confidence_threshold: Option<f32>,
52
53    /// Suggestor IDs whose proposals always require HITL.
54    /// Empty means no agent-based gating.
55    pub gated_agent_ids: Vec<String>,
56
57    /// Timeout behavior when human doesn't respond.
58    pub timeout: TimeoutPolicy,
59}
60
61impl HitlPolicy {
62    /// Create a policy that gates all proposals (strictest).
63    pub fn gate_all() -> Self {
64        Self {
65            gated_kinds: Vec::new(),
66            confidence_threshold: Some(1.0), // everything below 1.0 = everything
67            gated_agent_ids: Vec::new(),
68            timeout: TimeoutPolicy::default(),
69        }
70    }
71
72    /// Create a policy that gates specific content kinds.
73    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    /// Check if a proposal requires HITL approval under this policy.
83    pub fn requires_approval(&self, proposal: &Proposal<Draft>, agent_id: &str) -> bool {
84        // Kind-based gating
85        if !self.gated_kinds.is_empty() && self.gated_kinds.contains(&proposal.content().kind) {
86            return true;
87        }
88
89        // Confidence-based gating
90        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                // No confidence score + threshold configured = gate it
97                return true;
98            }
99        }
100
101        // Suggestor-based gating
102        if self.gated_agent_ids.contains(&agent_id.to_string()) {
103            return true;
104        }
105
106        false
107    }
108
109    /// Set custom timeout policy.
110    pub fn with_timeout(mut self, timeout: TimeoutPolicy) -> Self {
111        self.timeout = timeout;
112        self
113    }
114}
115
116// ============================================================================
117// TimeoutPolicy — what happens when human doesn't respond?
118// ============================================================================
119
120/// What happens when the human doesn't respond in time.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct TimeoutPolicy {
123    /// How long to wait before timeout action (in seconds).
124    pub timeout_secs: u64,
125
126    /// What to do on timeout.
127    pub action: TimeoutAction,
128}
129
130impl TimeoutPolicy {
131    /// Get timeout as Duration.
132    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, // 30 minutes
141            action: TimeoutAction::Reject,
142        }
143    }
144}
145
146/// Action taken when HITL gate times out.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148pub enum TimeoutAction {
149    /// Auto-reject the proposal (safe default).
150    Reject,
151    /// Auto-approve the proposal (use with caution).
152    Approve,
153    /// Escalate to a different approver.
154    Escalate,
155}
156
157// ============================================================================
158// GateRequest — the payload emitted when engine pauses
159// ============================================================================
160
161/// Request for human approval, emitted when convergence pauses at a HITL gate.
162///
163/// The hosting application receives this and is responsible for notifying
164/// the human via whatever channel is configured (Slack, email, UI, etc.).
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct GateRequest {
167    /// Unique ID for this gate request (used to resume).
168    pub gate_id: GateId,
169
170    /// The proposal awaiting approval.
171    pub proposal_id: ProposalId,
172
173    /// Human-readable summary of what the agent proposed.
174    pub summary: String,
175
176    /// Which agent made the proposal.
177    pub agent_id: String,
178
179    /// Suggestor's stated reason for the proposal.
180    pub rationale: Option<String>,
181
182    /// Key data the agent used to make this proposal (for human context).
183    pub context_data: Vec<ContextItem>,
184
185    /// The convergence cycle that was interrupted.
186    pub cycle: u32,
187
188    /// When the gate was triggered.
189    pub requested_at: Timestamp,
190
191    /// Timeout policy in effect.
192    pub timeout: TimeoutPolicy,
193}
194
195/// A key-value pair of context data for human review.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ContextItem {
198    /// Label for this data point.
199    pub label: String,
200    /// Value (plain text).
201    pub value: String,
202}
203
204impl ContextItem {
205    /// Create a new context item.
206    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    /// Create a new gate request from a proposal.
216    #[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    /// Add rationale for the proposal.
237    pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
238        self.rationale = Some(rationale.into());
239        self
240    }
241
242    /// Add context data for human review.
243    pub fn with_context(mut self, items: Vec<ContextItem>) -> Self {
244        self.context_data = items;
245        self
246    }
247}
248
249// ============================================================================
250// GateDecision — the human's response
251// ============================================================================
252
253/// Human's decision on a HITL gate request.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct GateDecision {
256    /// The gate ID this decision responds to.
257    pub gate_id: GateId,
258
259    /// The decision.
260    pub verdict: GateVerdict,
261
262    /// Who made the decision.
263    pub decided_by: String,
264
265    /// When the decision was made.
266    pub decided_at: Timestamp,
267}
268
269/// The actual approve/reject verdict.
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub enum GateVerdict {
272    /// Human approved the proposal.
273    Approve,
274    /// Human rejected the proposal, with optional reason.
275    Reject {
276        /// Why the proposal was rejected (free text).
277        reason: Option<String>,
278    },
279}
280
281impl GateDecision {
282    /// Create an approval decision.
283    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    /// Create a rejection decision.
293    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    /// Is this an approval?
303    pub fn is_approved(&self) -> bool {
304        matches!(self.verdict, GateVerdict::Approve)
305    }
306}
307
308// ============================================================================
309// GateEvent — audit trail entry
310// ============================================================================
311
312/// Audit trail entry for HITL gate events.
313///
314/// Every gate interaction is recorded for compliance and debugging.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct GateEvent {
317    /// The gate this event belongs to.
318    pub gate_id: GateId,
319    /// What happened.
320    pub kind: GateEventKind,
321    /// When it happened.
322    pub timestamp: Timestamp,
323}
324
325/// Kind of gate event for audit trail.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub enum GateEventKind {
328    /// Gate was triggered, convergence paused.
329    Requested {
330        /// ID of the proposal awaiting approval.
331        proposal_id: ProposalId,
332        /// ID of the agent that created the proposal.
333        agent_id: String,
334    },
335    /// Human approved the proposal.
336    Approved {
337        /// Who approved.
338        decided_by: String,
339    },
340    /// Human rejected the proposal.
341    Rejected {
342        /// Who rejected.
343        decided_by: String,
344        /// Reason for rejection.
345        reason: Option<String>,
346    },
347    /// Gate timed out.
348    TimedOut {
349        /// What automatic action was taken.
350        action_taken: TimeoutAction,
351    },
352}
353
354impl GateEvent {
355    /// Create a "requested" event.
356    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    /// Create an event from a decision.
368    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    /// Create a "timed out" event.
386    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// ============================================================================
396// PendingGate — engine-internal state for a paused HITL gate
397// ============================================================================
398
399/// Engine-internal state tracking a pending HITL gate.
400///
401/// Stored in the engine when convergence pauses. Contains everything
402/// needed to resume after a human decision.
403#[derive(Debug, Clone)]
404#[allow(dead_code)]
405pub(crate) struct PendingGate {
406    /// The gate request that was emitted.
407    pub request: GateRequest,
408    /// The draft proposal awaiting approval.
409    pub proposal: Proposal<Draft>,
410    /// The agent that produced this proposal.
411    pub agent_id: String,
412    /// Cycle at which convergence was paused.
413    pub paused_at_cycle: u32,
414}
415
416// ============================================================================
417// Helpers
418// ============================================================================
419
420/// Generate a deterministic pseudo-UUID from a process-local sequence.
421/// Good enough for gate IDs; not cryptographic.
422#[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// ============================================================================
433// Tests
434// ============================================================================
435
436#[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")); // no confidence = gated
484    }
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        // confidence 0.99 < threshold 1.0 = gated
505        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}