Skip to main content

converge_core/traits/
promoter.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! # Promoter Capability Boundary Trait
5//!
6//! This module defines the capability boundary trait for promoting validated
7//! proposals to Facts. Promoters take `Proposal<Validated>` (which requires proof
8//! of validation) and produce `Fact` with complete audit trail.
9//!
10//! ## Design Philosophy
11//!
12//! - **Type-state enforcement:** Works with `Proposal<Validated>` which can only
13//!   be created after validation. This ensures "no bypass path" at the type level.
14//!
15//! - **Fact immutability:** Once a `Fact` is created, it's immutable. Corrections
16//!   are new events, not mutations (append-only principle).
17//!
18//! - **GAT async pattern:** Uses generic associated types for zero-cost async
19//!   without proc macros or `async_trait`. Keeps core dependency-free.
20//!
21//! - **Split from validation:** Promotion is a separate capability from validation.
22//!   Different authorization boundaries and audit trails.
23//!
24//! ## Integration with Gate Pattern
25//!
26//! The `Promoter` trait abstracts the promotion capability that `PromotionGate`
27//! uses internally. This allows:
28//! - Swapping promotion implementations (immediate, queued, consensus-based)
29//! - Testing with mock promoters
30//! - Distributed promotion across services
31//!
32//! ## Error Handling
33//!
34//! [`PromoterError`] implements [`CapabilityError`](super::error::CapabilityError)
35//! for uniform error classification, enabling generic retry/circuit breaker logic.
36
37use super::error::{CapabilityError, ErrorCategory};
38use std::future::Future;
39use std::pin::Pin;
40use std::time::Duration;
41
42use crate::gates::validation::ValidationReport;
43use crate::types::{Actor, EvidenceRef, Fact, Proposal, TraceLink, Validated};
44
45/// Boxed future type for dyn-safe trait variant.
46pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
47
48// ============================================================================
49// Promotion Context
50// ============================================================================
51
52/// Context for promotion operations.
53///
54/// Contains the metadata and evidence required for creating the promotion record.
55#[derive(Debug, Clone)]
56pub struct PromotionContext {
57    /// Actor approving the promotion.
58    pub approver: Actor,
59    /// Evidence references supporting the promotion.
60    pub evidence: Vec<EvidenceRef>,
61    /// Trace link for audit/replay.
62    pub trace: TraceLink,
63}
64
65impl PromotionContext {
66    /// Create a new promotion context.
67    pub fn new(approver: Actor, trace: TraceLink) -> Self {
68        Self {
69            approver,
70            evidence: Vec::new(),
71            trace,
72        }
73    }
74
75    /// Add evidence to the context.
76    pub fn with_evidence(mut self, evidence: Vec<EvidenceRef>) -> Self {
77        self.evidence = evidence;
78        self
79    }
80
81    /// Add a single evidence reference.
82    pub fn with_evidence_ref(mut self, evidence: EvidenceRef) -> Self {
83        self.evidence.push(evidence);
84        self
85    }
86}
87
88// ============================================================================
89// Error Type
90// ============================================================================
91
92/// Error type for promotion operations.
93///
94/// Implements [`CapabilityError`] for uniform error classification.
95#[derive(Debug, Clone)]
96pub enum PromoterError {
97    /// Validation report doesn't match proposal.
98    ReportMismatch {
99        /// Expected proposal ID from report.
100        expected: String,
101        /// Actual proposal ID.
102        actual: String,
103    },
104    /// Actor not authorized to promote.
105    Unauthorized {
106        /// Actor that attempted promotion.
107        actor: String,
108        /// Reason for denial.
109        reason: String,
110    },
111    /// Proposal already promoted.
112    AlreadyPromoted {
113        /// Proposal ID.
114        proposal_id: String,
115        /// Existing fact ID.
116        fact_id: String,
117    },
118    /// Promoter service unavailable.
119    Unavailable {
120        /// Error message.
121        message: String,
122    },
123    /// Operation timed out.
124    Timeout {
125        /// Time elapsed before timeout.
126        elapsed: Duration,
127        /// Configured deadline.
128        deadline: Duration,
129    },
130    /// Storage error during fact creation.
131    StorageError {
132        /// Error message.
133        message: String,
134    },
135    /// Internal promoter error.
136    Internal {
137        /// Error message.
138        message: String,
139    },
140}
141
142impl std::fmt::Display for PromoterError {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        match self {
145            Self::ReportMismatch { expected, actual } => {
146                write!(
147                    f,
148                    "validation report mismatch: expected proposal '{}', got '{}'",
149                    expected, actual
150                )
151            }
152            Self::Unauthorized { actor, reason } => {
153                write!(f, "actor '{}' not authorized: {}", actor, reason)
154            }
155            Self::AlreadyPromoted {
156                proposal_id,
157                fact_id,
158            } => {
159                write!(
160                    f,
161                    "proposal '{}' already promoted to fact '{}'",
162                    proposal_id, fact_id
163                )
164            }
165            Self::Unavailable { message } => write!(f, "promoter unavailable: {}", message),
166            Self::Timeout { elapsed, deadline } => {
167                write!(
168                    f,
169                    "promotion timeout after {:?} (deadline: {:?})",
170                    elapsed, deadline
171                )
172            }
173            Self::StorageError { message } => write!(f, "storage error: {}", message),
174            Self::Internal { message } => write!(f, "internal promoter error: {}", message),
175        }
176    }
177}
178
179impl std::error::Error for PromoterError {}
180
181impl CapabilityError for PromoterError {
182    fn category(&self) -> ErrorCategory {
183        match self {
184            Self::ReportMismatch { .. } => ErrorCategory::InvalidInput,
185            Self::Unauthorized { .. } => ErrorCategory::Auth,
186            Self::AlreadyPromoted { .. } => ErrorCategory::Conflict,
187            Self::Unavailable { .. } => ErrorCategory::Unavailable,
188            Self::Timeout { .. } => ErrorCategory::Timeout,
189            Self::StorageError { .. } => ErrorCategory::Internal,
190            Self::Internal { .. } => ErrorCategory::Internal,
191        }
192    }
193
194    fn is_transient(&self) -> bool {
195        matches!(
196            self,
197            Self::Unavailable { .. } | Self::Timeout { .. } | Self::StorageError { .. }
198        )
199    }
200
201    fn is_retryable(&self) -> bool {
202        // Transient errors are retryable
203        // Internal errors may also be retryable (temporary service issues)
204        // AlreadyPromoted is NOT retryable (idempotency check)
205        self.is_transient() || matches!(self, Self::Internal { .. })
206    }
207
208    fn retry_after(&self) -> Option<Duration> {
209        // No specific retry-after for promotion errors
210        None
211    }
212}
213
214// ============================================================================
215// Static Dispatch Trait (GAT Async Pattern)
216// ============================================================================
217
218/// Proposal promotion capability.
219///
220/// Promotes `Proposal<Validated>` to `Fact` with complete audit trail.
221/// This trait uses the GAT async pattern for zero-cost static dispatch.
222///
223/// # Type-State Integration
224///
225/// Works with the type-state pattern established in Phase 4:
226/// - Input: `Proposal<Validated>` - can only be created after validation
227/// - Input: `ValidationReport` - proof that validation occurred
228/// - Output: `Fact` - immutable record with promotion provenance
229///
230/// The type system ensures no bypass path: you cannot call `promote` without
231/// first having a `Proposal<Validated>` (which requires validation).
232///
233/// # Example Implementation
234///
235/// ```ignore
236/// struct ImmediatePromoter {
237///     store: Arc<dyn FactStore>,
238/// }
239///
240/// impl Promoter for ImmediatePromoter {
241///     type PromoteFut<'a> = impl Future<Output = Result<Fact, PromoterError>> + Send + 'a
242///     where
243///         Self: 'a;
244///
245///     fn promote<'a>(
246///         &'a self,
247///         proposal: Proposal<Validated>,
248///         report: &'a ValidationReport,
249///         context: &'a PromotionContext,
250///     ) -> Self::PromoteFut<'a> {
251///         async move {
252///             // Create fact and store...
253///             Ok(fact)
254///         }
255///     }
256/// }
257/// ```
258pub trait Promoter: Send + Sync {
259    /// Associated future type for promotion.
260    ///
261    /// Must be `Send` to work with multi-threaded runtimes.
262    type PromoteFut<'a>: Future<Output = Result<Fact, PromoterError>> + Send + 'a
263    where
264        Self: 'a;
265
266    /// Promote a validated proposal to a Fact.
267    ///
268    /// # Arguments
269    ///
270    /// * `proposal` - The validated proposal to promote (consumed).
271    /// * `report` - The validation report (proof of validation).
272    /// * `context` - Promotion context (approver, evidence, trace).
273    ///
274    /// # Returns
275    ///
276    /// A future that resolves to the created Fact or an error.
277    /// The Fact includes complete promotion provenance.
278    fn promote<'a>(
279        &'a self,
280        proposal: Proposal<Validated>,
281        report: &'a ValidationReport,
282        context: &'a PromotionContext,
283    ) -> Self::PromoteFut<'a>;
284}
285
286// ============================================================================
287// Dyn-Safe Wrapper (Runtime Polymorphism)
288// ============================================================================
289
290/// Dyn-safe promoter for runtime polymorphism.
291///
292/// Use this trait when you need `dyn Trait` compatibility, such as:
293/// - Storing multiple promoter types in a collection
294/// - Runtime routing between different promotion strategies
295/// - Plugin systems with dynamic loading
296///
297/// For static dispatch (better performance, no allocation), use [`Promoter`].
298///
299/// # Blanket Implementation
300///
301/// Any type implementing [`Promoter`] automatically implements [`DynPromoter`]
302/// via a blanket impl that boxes the future.
303pub trait DynPromoter: Send + Sync {
304    /// Promote a validated proposal to a Fact.
305    ///
306    /// Returns a boxed future for dyn-safety.
307    fn promote<'a>(
308        &'a self,
309        proposal: Proposal<Validated>,
310        report: &'a ValidationReport,
311        context: &'a PromotionContext,
312    ) -> BoxFuture<'a, Result<Fact, PromoterError>>;
313}
314
315// Blanket implementation: Promoter -> DynPromoter
316impl<T: Promoter> DynPromoter for T {
317    fn promote<'a>(
318        &'a self,
319        proposal: Proposal<Validated>,
320        report: &'a ValidationReport,
321        context: &'a PromotionContext,
322    ) -> BoxFuture<'a, Result<Fact, PromoterError>> {
323        Box::pin(Promoter::promote(self, proposal, report, context))
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::traits::error::{CapabilityError, ErrorCategory};
331
332    // ── PromoterError Display ────────────────────────────────────────────────
333
334    #[test]
335    fn display_report_mismatch() {
336        let e = PromoterError::ReportMismatch {
337            expected: "p-1".into(),
338            actual: "p-2".into(),
339        };
340        let s = e.to_string();
341        assert!(s.contains("p-1"));
342        assert!(s.contains("p-2"));
343    }
344
345    #[test]
346    fn display_unauthorized() {
347        let e = PromoterError::Unauthorized {
348            actor: "bot-7".into(),
349            reason: "no promote scope".into(),
350        };
351        let s = e.to_string();
352        assert!(s.contains("bot-7"));
353        assert!(s.contains("no promote scope"));
354    }
355
356    #[test]
357    fn display_already_promoted() {
358        let e = PromoterError::AlreadyPromoted {
359            proposal_id: "p-1".into(),
360            fact_id: "f-1".into(),
361        };
362        let s = e.to_string();
363        assert!(s.contains("p-1"));
364        assert!(s.contains("f-1"));
365    }
366
367    #[test]
368    fn display_unavailable() {
369        let e = PromoterError::Unavailable {
370            message: "service down".into(),
371        };
372        assert!(e.to_string().contains("service down"));
373    }
374
375    #[test]
376    fn display_timeout() {
377        let e = PromoterError::Timeout {
378            elapsed: Duration::from_millis(500),
379            deadline: Duration::from_millis(200),
380        };
381        let s = e.to_string();
382        assert!(s.contains("500ms"));
383        assert!(s.contains("200ms"));
384    }
385
386    #[test]
387    fn display_storage_error() {
388        let e = PromoterError::StorageError {
389            message: "write failed".into(),
390        };
391        assert!(e.to_string().contains("write failed"));
392    }
393
394    #[test]
395    fn display_internal() {
396        let e = PromoterError::Internal {
397            message: "bug".into(),
398        };
399        assert!(e.to_string().contains("bug"));
400    }
401
402    // ── CapabilityError classification ───────────────────────────────────────
403
404    #[test]
405    fn category_report_mismatch_is_invalid_input() {
406        let e = PromoterError::ReportMismatch {
407            expected: "x".into(),
408            actual: "y".into(),
409        };
410        assert_eq!(e.category(), ErrorCategory::InvalidInput);
411        assert!(!e.is_transient());
412        assert!(!e.is_retryable());
413    }
414
415    #[test]
416    fn category_unauthorized_is_auth() {
417        let e = PromoterError::Unauthorized {
418            actor: "x".into(),
419            reason: "y".into(),
420        };
421        assert_eq!(e.category(), ErrorCategory::Auth);
422        assert!(!e.is_transient());
423        assert!(!e.is_retryable());
424    }
425
426    #[test]
427    fn category_already_promoted_is_conflict() {
428        let e = PromoterError::AlreadyPromoted {
429            proposal_id: "p".into(),
430            fact_id: "f".into(),
431        };
432        assert_eq!(e.category(), ErrorCategory::Conflict);
433        assert!(!e.is_transient());
434        assert!(!e.is_retryable());
435    }
436
437    #[test]
438    fn category_unavailable_is_transient_and_retryable() {
439        let e = PromoterError::Unavailable {
440            message: "x".into(),
441        };
442        assert_eq!(e.category(), ErrorCategory::Unavailable);
443        assert!(e.is_transient());
444        assert!(e.is_retryable());
445    }
446
447    #[test]
448    fn category_timeout_is_transient_and_retryable() {
449        let e = PromoterError::Timeout {
450            elapsed: Duration::from_secs(1),
451            deadline: Duration::from_secs(1),
452        };
453        assert_eq!(e.category(), ErrorCategory::Timeout);
454        assert!(e.is_transient());
455        assert!(e.is_retryable());
456    }
457
458    #[test]
459    fn category_storage_error_is_internal_transient_retryable() {
460        let e = PromoterError::StorageError {
461            message: "x".into(),
462        };
463        assert_eq!(e.category(), ErrorCategory::Internal);
464        assert!(e.is_transient());
465        assert!(e.is_retryable());
466    }
467
468    #[test]
469    fn category_internal_is_retryable_not_transient() {
470        let e = PromoterError::Internal {
471            message: "x".into(),
472        };
473        assert_eq!(e.category(), ErrorCategory::Internal);
474        assert!(!e.is_transient());
475        assert!(e.is_retryable());
476    }
477
478    #[test]
479    fn retry_after_always_none() {
480        let errors: Vec<PromoterError> = vec![
481            PromoterError::Unavailable {
482                message: "x".into(),
483            },
484            PromoterError::Timeout {
485                elapsed: Duration::from_secs(1),
486                deadline: Duration::from_secs(1),
487            },
488            PromoterError::Internal {
489                message: "x".into(),
490            },
491        ];
492        for e in &errors {
493            assert!(e.retry_after().is_none());
494        }
495    }
496
497    // ── PromotionContext builder ─────────────────────────────────────────────
498
499    fn sample_actor() -> Actor {
500        Actor::human("karl")
501    }
502
503    fn sample_trace() -> TraceLink {
504        TraceLink::local(crate::types::LocalTrace::new("trace-1", "span-1"))
505    }
506
507    #[test]
508    fn promotion_context_new_has_no_evidence() {
509        let ctx = PromotionContext::new(sample_actor(), sample_trace());
510        assert!(ctx.evidence.is_empty());
511    }
512
513    #[test]
514    fn promotion_context_with_evidence() {
515        let evidence = vec![
516            EvidenceRef::observation("obs-1".into()),
517            EvidenceRef::human_approval("approval-1".into()),
518        ];
519        let ctx = PromotionContext::new(sample_actor(), sample_trace()).with_evidence(evidence);
520        assert_eq!(ctx.evidence.len(), 2);
521    }
522
523    #[test]
524    fn promotion_context_with_evidence_ref() {
525        let ctx = PromotionContext::new(sample_actor(), sample_trace())
526            .with_evidence_ref(EvidenceRef::observation("obs-1".into()))
527            .with_evidence_ref(EvidenceRef::derived("art-1".into()));
528        assert_eq!(ctx.evidence.len(), 2);
529    }
530
531    // ── std::error::Error ────────────────────────────────────────────────────
532
533    #[test]
534    fn promoter_error_is_std_error() {
535        let e: Box<dyn std::error::Error> = Box::new(PromoterError::Internal {
536            message: "test".into(),
537        });
538        assert!(e.to_string().contains("test"));
539    }
540}