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 simple pseudo-UUID from timestamp.
421/// Good enough for gate IDs; not cryptographic.
422#[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// ============================================================================
441// Tests
442// ============================================================================
443
444#[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")); // no confidence = gated
492    }
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        // confidence 0.99 < threshold 1.0 = gated
513        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}