1use 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
45pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
47
48#[derive(Debug, Clone)]
56pub struct PromotionContext {
57 pub approver: Actor,
59 pub evidence: Vec<EvidenceRef>,
61 pub trace: TraceLink,
63}
64
65impl PromotionContext {
66 pub fn new(approver: Actor, trace: TraceLink) -> Self {
68 Self {
69 approver,
70 evidence: Vec::new(),
71 trace,
72 }
73 }
74
75 pub fn with_evidence(mut self, evidence: Vec<EvidenceRef>) -> Self {
77 self.evidence = evidence;
78 self
79 }
80
81 pub fn with_evidence_ref(mut self, evidence: EvidenceRef) -> Self {
83 self.evidence.push(evidence);
84 self
85 }
86}
87
88#[derive(Debug, Clone)]
96pub enum PromoterError {
97 ReportMismatch {
99 expected: String,
101 actual: String,
103 },
104 Unauthorized {
106 actor: String,
108 reason: String,
110 },
111 AlreadyPromoted {
113 proposal_id: String,
115 fact_id: String,
117 },
118 Unavailable {
120 message: String,
122 },
123 Timeout {
125 elapsed: Duration,
127 deadline: Duration,
129 },
130 StorageError {
132 message: String,
134 },
135 Internal {
137 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 self.is_transient() || matches!(self, Self::Internal { .. })
206 }
207
208 fn retry_after(&self) -> Option<Duration> {
209 None
211 }
212}
213
214pub trait Promoter: Send + Sync {
259 type PromoteFut<'a>: Future<Output = Result<Fact, PromoterError>> + Send + 'a
263 where
264 Self: 'a;
265
266 fn promote<'a>(
279 &'a self,
280 proposal: Proposal<Validated>,
281 report: &'a ValidationReport,
282 context: &'a PromotionContext,
283 ) -> Self::PromoteFut<'a>;
284}
285
286pub trait DynPromoter: Send + Sync {
304 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
315impl<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 #[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 #[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 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 #[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}