1use chrono::{DateTime, Utc};
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27
28use crate::decision::{PolicyDecision, PolicyDecisionKind};
29
30pub const PROMOTION_ANCHOR: &str = "champion-challenger";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
39#[serde(rename_all = "snake_case")]
40pub enum PromotionStatus {
41 #[default]
43 Shadow,
44 Promoted,
46 Denied,
49 AwaitingApproval,
51}
52
53impl PromotionStatus {
54 pub fn as_str(self) -> &'static str {
55 match self {
56 PromotionStatus::Shadow => "shadow",
57 PromotionStatus::Promoted => "promoted",
58 PromotionStatus::Denied => "denied",
59 PromotionStatus::AwaitingApproval => "awaiting_approval",
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct MetricThreshold {
73 pub name: String,
75 pub min_value: f64,
77}
78
79impl MetricThreshold {
80 pub fn new(name: impl Into<String>, min_value: f64) -> Self {
81 Self {
82 name: name.into(),
83 min_value,
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct PromotionGateConfig {
92 pub thresholds: Vec<MetricThreshold>,
96 pub require_human_approval: bool,
99}
100
101impl Default for PromotionGateConfig {
102 fn default() -> Self {
103 Self {
106 thresholds: Vec::new(),
107 require_human_approval: true,
108 }
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct MetricEvidence {
115 pub name: String,
116 pub min_value: f64,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub measured_value: Option<f64>,
122 pub passed: bool,
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct PromotionDecision {
134 pub id: String,
136 pub agent_id: String,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub champion_version_id: Option<String>,
142 pub challenger_version_id: String,
144 pub decision_kind: PolicyDecisionKind,
146 pub status: PromotionStatus,
148 pub reason: String,
150 pub metric_evidence: Vec<MetricEvidence>,
152 pub approval_present: bool,
154 pub approval_required: bool,
156 pub content_hash: String,
159 pub decided_at: DateTime<Utc>,
161 #[serde(default = "default_anchor")]
163 pub anchor: String,
164}
165
166fn default_anchor() -> String {
167 PROMOTION_ANCHOR.to_string()
168}
169
170impl PromotionDecision {
171 pub fn to_audit_details(&self) -> serde_json::Value {
173 serde_json::to_value(self).expect("PromotionDecision must always serialise")
174 }
175
176 pub fn from_audit_details(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
179 serde_json::from_value(value.clone())
180 }
181
182 pub fn verify_hash(&self) -> bool {
185 compute_content_hash(
186 &self.agent_id,
187 self.champion_version_id.as_deref(),
188 &self.challenger_version_id,
189 self.decision_kind,
190 self.status,
191 &self.metric_evidence,
192 self.approval_present,
193 self.approval_required,
194 ) == self.content_hash
195 }
196}
197
198#[derive(Debug, Clone, Default)]
201pub struct PromotionGate;
202
203impl PromotionGate {
204 pub fn evaluate(
216 &self,
217 config: &PromotionGateConfig,
218 metrics: &[(String, f64)],
219 approval_present: bool,
220 ) -> PolicyDecision {
221 let evidence = build_evidence(config, metrics);
222
223 if config.thresholds.is_empty() {
224 return PolicyDecision::deny(
225 "promotion denied: no metric thresholds configured (deny-by-default; \
226 challenger stays shadow)",
227 );
228 }
229
230 let failed: Vec<&MetricEvidence> = evidence.iter().filter(|e| !e.passed).collect();
231 if !failed.is_empty() {
232 let names: Vec<&str> = failed.iter().map(|e| e.name.as_str()).collect();
233 return PolicyDecision::deny(format!(
234 "promotion denied: challenger below threshold on [{}] (challenger stays shadow)",
235 names.join(", ")
236 ));
237 }
238
239 if config.require_human_approval && !approval_present {
240 return PolicyDecision::requires_approval(
241 "promotion thresholds cleared; awaiting required human approval before promote",
242 );
243 }
244
245 PolicyDecision::allow(
246 "promotion allowed: all thresholds cleared and approval satisfied — challenger \
247 promoted to champion",
248 )
249 }
250
251 #[allow(clippy::too_many_arguments)]
256 pub fn decide(
257 &self,
258 id: impl Into<String>,
259 agent_id: impl Into<String>,
260 champion_version_id: Option<String>,
261 challenger_version_id: impl Into<String>,
262 config: &PromotionGateConfig,
263 metrics: &[(String, f64)],
264 approval_present: bool,
265 decision: &PolicyDecision,
266 decided_at: DateTime<Utc>,
267 ) -> PromotionDecision {
268 let agent_id = agent_id.into();
269 let challenger_version_id = challenger_version_id.into();
270 let evidence = build_evidence(config, metrics);
271 let status = status_for(decision.kind);
272 let content_hash = compute_content_hash(
273 &agent_id,
274 champion_version_id.as_deref(),
275 &challenger_version_id,
276 decision.kind,
277 status,
278 &evidence,
279 approval_present,
280 config.require_human_approval,
281 );
282 PromotionDecision {
283 id: id.into(),
284 agent_id,
285 champion_version_id,
286 challenger_version_id,
287 decision_kind: decision.kind,
288 status,
289 reason: decision.reason.clone(),
290 metric_evidence: evidence,
291 approval_present,
292 approval_required: config.require_human_approval,
293 content_hash,
294 decided_at,
295 anchor: PROMOTION_ANCHOR.to_string(),
296 }
297 }
298}
299
300fn status_for(kind: PolicyDecisionKind) -> PromotionStatus {
302 match kind {
303 PolicyDecisionKind::Allow | PolicyDecisionKind::AllowWithWarning => {
304 PromotionStatus::Promoted
305 }
306 PolicyDecisionKind::RequiresApproval => PromotionStatus::AwaitingApproval,
307 PolicyDecisionKind::Deny => PromotionStatus::Denied,
308 }
309}
310
311fn build_evidence(config: &PromotionGateConfig, metrics: &[(String, f64)]) -> Vec<MetricEvidence> {
315 config
316 .thresholds
317 .iter()
318 .map(|t| {
319 let measured = metrics
320 .iter()
321 .find(|(name, _)| name == &t.name)
322 .map(|(_, v)| *v);
323 let passed = measured.map(|v| v >= t.min_value).unwrap_or(false);
324 MetricEvidence {
325 name: t.name.clone(),
326 min_value: t.min_value,
327 measured_value: measured,
328 passed,
329 }
330 })
331 .collect()
332}
333
334#[allow(clippy::too_many_arguments)]
337fn compute_content_hash(
338 agent_id: &str,
339 champion_version_id: Option<&str>,
340 challenger_version_id: &str,
341 decision_kind: PolicyDecisionKind,
342 status: PromotionStatus,
343 evidence: &[MetricEvidence],
344 approval_present: bool,
345 approval_required: bool,
346) -> String {
347 #[derive(Serialize)]
348 struct HashableProjection<'a> {
349 agent_id: &'a str,
350 champion_version_id: Option<&'a str>,
351 challenger_version_id: &'a str,
352 decision_kind: PolicyDecisionKind,
353 status: &'a str,
354 evidence: &'a [MetricEvidence],
355 approval_present: bool,
356 approval_required: bool,
357 }
358 let projection = HashableProjection {
359 agent_id,
360 champion_version_id,
361 challenger_version_id,
362 decision_kind,
363 status: status.as_str(),
364 evidence,
365 approval_present,
366 approval_required,
367 };
368 let bytes = serde_json::to_vec(&projection).expect("projection must serialise");
369 let digest = Sha256::digest(&bytes);
370 hex::encode(digest)
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 fn ts() -> DateTime<Utc> {
378 DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid timestamp")
379 }
380
381 fn config(require_approval: bool) -> PromotionGateConfig {
382 PromotionGateConfig {
383 thresholds: vec![
384 MetricThreshold::new("eval_pass_rate", 0.90),
385 MetricThreshold::new("bench_trust_score", 0.70),
386 ],
387 require_human_approval: require_approval,
388 }
389 }
390
391 #[test]
392 fn empty_thresholds_deny_by_default() {
393 let gate = PromotionGate;
394 let cfg = PromotionGateConfig::default();
395 let decision = gate.evaluate(&cfg, &[("eval_pass_rate".into(), 0.99)], true);
396 assert!(decision.is_denied());
397 assert!(decision.reason.contains("no metric thresholds"));
398 }
399
400 #[test]
401 fn below_threshold_is_denied() {
402 let gate = PromotionGate;
403 let cfg = config(true);
404 let metrics = vec![
406 ("eval_pass_rate".to_string(), 0.95),
407 ("bench_trust_score".to_string(), 0.50),
408 ];
409 let decision = gate.evaluate(&cfg, &metrics, true);
410 assert!(decision.is_denied());
411 assert!(decision.reason.contains("bench_trust_score"));
412 }
413
414 #[test]
415 fn missing_metric_is_treated_as_fail() {
416 let gate = PromotionGate;
417 let cfg = config(false);
418 let metrics = vec![("eval_pass_rate".to_string(), 0.95)];
420 let decision = gate.evaluate(&cfg, &metrics, false);
421 assert!(decision.is_denied());
422 }
423
424 #[test]
425 fn cleared_thresholds_without_approval_requires_approval() {
426 let gate = PromotionGate;
427 let cfg = config(true);
428 let metrics = vec![
429 ("eval_pass_rate".to_string(), 0.95),
430 ("bench_trust_score".to_string(), 0.80),
431 ];
432 let decision = gate.evaluate(&cfg, &metrics, false);
433 assert!(decision.needs_approval());
434 }
435
436 #[test]
437 fn cleared_thresholds_with_approval_is_allowed() {
438 let gate = PromotionGate;
439 let cfg = config(true);
440 let metrics = vec![
441 ("eval_pass_rate".to_string(), 0.95),
442 ("bench_trust_score".to_string(), 0.80),
443 ];
444 let decision = gate.evaluate(&cfg, &metrics, true);
445 assert!(decision.is_allowed());
446 }
447
448 #[test]
449 fn approval_not_required_allows_on_metrics_alone() {
450 let gate = PromotionGate;
451 let cfg = config(false);
452 let metrics = vec![
453 ("eval_pass_rate".to_string(), 0.95),
454 ("bench_trust_score".to_string(), 0.80),
455 ];
456 let decision = gate.evaluate(&cfg, &metrics, false);
457 assert!(decision.is_allowed());
458 }
459
460 #[test]
461 fn threshold_is_inclusive_floor() {
462 let gate = PromotionGate;
463 let cfg = PromotionGateConfig {
464 thresholds: vec![MetricThreshold::new("eval_pass_rate", 0.90)],
465 require_human_approval: false,
466 };
467 let decision = gate.evaluate(&cfg, &[("eval_pass_rate".into(), 0.90)], false);
469 assert!(decision.is_allowed());
470 }
471
472 #[test]
473 fn decide_builds_record_with_status_and_hash() {
474 let gate = PromotionGate;
475 let cfg = config(true);
476 let metrics = vec![
477 ("eval_pass_rate".to_string(), 0.95),
478 ("bench_trust_score".to_string(), 0.80),
479 ];
480 let decision = gate.evaluate(&cfg, &metrics, true);
481 let record = gate.decide(
482 "prm_test_001",
483 "agt_demo",
484 Some("agv_champion".into()),
485 "agv_challenger",
486 &cfg,
487 &metrics,
488 true,
489 &decision,
490 ts(),
491 );
492 assert_eq!(record.status, PromotionStatus::Promoted);
493 assert_eq!(record.decision_kind, PolicyDecisionKind::Allow);
494 assert_eq!(record.metric_evidence.len(), 2);
495 assert!(record.metric_evidence.iter().all(|e| e.passed));
496 assert_eq!(record.content_hash.len(), 64);
497 assert!(record.verify_hash());
498 }
499
500 #[test]
501 fn denied_decide_records_denied_status() {
502 let gate = PromotionGate;
503 let cfg = config(true);
504 let metrics = vec![
505 ("eval_pass_rate".to_string(), 0.50),
506 ("bench_trust_score".to_string(), 0.80),
507 ];
508 let decision = gate.evaluate(&cfg, &metrics, true);
509 let record = gate.decide(
510 "prm_test_002",
511 "agt_demo",
512 None,
513 "agv_challenger",
514 &cfg,
515 &metrics,
516 true,
517 &decision,
518 ts(),
519 );
520 assert_eq!(record.status, PromotionStatus::Denied);
521 assert!(record.verify_hash());
522 }
523
524 #[test]
525 fn audit_details_round_trip_preserves_record() {
526 let gate = PromotionGate;
527 let cfg = config(false);
528 let metrics = vec![
529 ("eval_pass_rate".to_string(), 0.95),
530 ("bench_trust_score".to_string(), 0.80),
531 ];
532 let decision = gate.evaluate(&cfg, &metrics, false);
533 let record = gate.decide(
534 "prm_test_003",
535 "agt_demo",
536 Some("agv_champion".into()),
537 "agv_challenger",
538 &cfg,
539 &metrics,
540 false,
541 &decision,
542 ts(),
543 );
544 let details = record.to_audit_details();
545 let parsed = PromotionDecision::from_audit_details(&details).expect("round-trip");
546 assert_eq!(parsed, record);
547 assert!(parsed.verify_hash());
548 }
549
550 #[test]
551 fn tampering_with_status_breaks_hash() {
552 let gate = PromotionGate;
553 let cfg = config(false);
554 let metrics = vec![
555 ("eval_pass_rate".to_string(), 0.95),
556 ("bench_trust_score".to_string(), 0.80),
557 ];
558 let decision = gate.evaluate(&cfg, &metrics, false);
559 let mut record = gate.decide(
560 "prm_test_004",
561 "agt_demo",
562 None,
563 "agv_challenger",
564 &cfg,
565 &metrics,
566 false,
567 &decision,
568 ts(),
569 );
570 record.status = PromotionStatus::Denied;
573 assert!(!record.verify_hash());
574 }
575
576 #[test]
577 fn status_serialises_snake_case() {
578 for (status, expected) in [
579 (PromotionStatus::Shadow, "\"shadow\""),
580 (PromotionStatus::Promoted, "\"promoted\""),
581 (PromotionStatus::Denied, "\"denied\""),
582 (PromotionStatus::AwaitingApproval, "\"awaiting_approval\""),
583 ] {
584 assert_eq!(serde_json::to_string(&status).unwrap(), expected);
585 }
586 }
587
588 #[test]
589 fn default_status_is_shadow() {
590 assert_eq!(PromotionStatus::default(), PromotionStatus::Shadow);
591 }
592}