use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::decision::{PolicyDecision, PolicyDecisionKind};
pub const PROMOTION_ANCHOR: &str = "champion-challenger";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PromotionStatus {
#[default]
Shadow,
Promoted,
Denied,
AwaitingApproval,
}
impl PromotionStatus {
pub fn as_str(self) -> &'static str {
match self {
PromotionStatus::Shadow => "shadow",
PromotionStatus::Promoted => "promoted",
PromotionStatus::Denied => "denied",
PromotionStatus::AwaitingApproval => "awaiting_approval",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetricThreshold {
pub name: String,
pub min_value: f64,
}
impl MetricThreshold {
pub fn new(name: impl Into<String>, min_value: f64) -> Self {
Self {
name: name.into(),
min_value,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PromotionGateConfig {
pub thresholds: Vec<MetricThreshold>,
pub require_human_approval: bool,
}
impl Default for PromotionGateConfig {
fn default() -> Self {
Self {
thresholds: Vec::new(),
require_human_approval: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetricEvidence {
pub name: String,
pub min_value: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub measured_value: Option<f64>,
pub passed: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PromotionDecision {
pub id: String,
pub agent_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub champion_version_id: Option<String>,
pub challenger_version_id: String,
pub decision_kind: PolicyDecisionKind,
pub status: PromotionStatus,
pub reason: String,
pub metric_evidence: Vec<MetricEvidence>,
pub approval_present: bool,
pub approval_required: bool,
pub content_hash: String,
pub decided_at: DateTime<Utc>,
#[serde(default = "default_anchor")]
pub anchor: String,
}
fn default_anchor() -> String {
PROMOTION_ANCHOR.to_string()
}
impl PromotionDecision {
pub fn to_audit_details(&self) -> serde_json::Value {
serde_json::to_value(self).expect("PromotionDecision must always serialise")
}
pub fn from_audit_details(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
serde_json::from_value(value.clone())
}
pub fn verify_hash(&self) -> bool {
compute_content_hash(
&self.agent_id,
self.champion_version_id.as_deref(),
&self.challenger_version_id,
self.decision_kind,
self.status,
&self.metric_evidence,
self.approval_present,
self.approval_required,
) == self.content_hash
}
}
#[derive(Debug, Clone, Default)]
pub struct PromotionGate;
impl PromotionGate {
pub fn evaluate(
&self,
config: &PromotionGateConfig,
metrics: &[(String, f64)],
approval_present: bool,
) -> PolicyDecision {
let evidence = build_evidence(config, metrics);
if config.thresholds.is_empty() {
return PolicyDecision::deny(
"promotion denied: no metric thresholds configured (deny-by-default; \
challenger stays shadow)",
);
}
let failed: Vec<&MetricEvidence> = evidence.iter().filter(|e| !e.passed).collect();
if !failed.is_empty() {
let names: Vec<&str> = failed.iter().map(|e| e.name.as_str()).collect();
return PolicyDecision::deny(format!(
"promotion denied: challenger below threshold on [{}] (challenger stays shadow)",
names.join(", ")
));
}
if config.require_human_approval && !approval_present {
return PolicyDecision::requires_approval(
"promotion thresholds cleared; awaiting required human approval before promote",
);
}
PolicyDecision::allow(
"promotion allowed: all thresholds cleared and approval satisfied — challenger \
promoted to champion",
)
}
#[allow(clippy::too_many_arguments)]
pub fn decide(
&self,
id: impl Into<String>,
agent_id: impl Into<String>,
champion_version_id: Option<String>,
challenger_version_id: impl Into<String>,
config: &PromotionGateConfig,
metrics: &[(String, f64)],
approval_present: bool,
decision: &PolicyDecision,
decided_at: DateTime<Utc>,
) -> PromotionDecision {
let agent_id = agent_id.into();
let challenger_version_id = challenger_version_id.into();
let evidence = build_evidence(config, metrics);
let status = status_for(decision.kind);
let content_hash = compute_content_hash(
&agent_id,
champion_version_id.as_deref(),
&challenger_version_id,
decision.kind,
status,
&evidence,
approval_present,
config.require_human_approval,
);
PromotionDecision {
id: id.into(),
agent_id,
champion_version_id,
challenger_version_id,
decision_kind: decision.kind,
status,
reason: decision.reason.clone(),
metric_evidence: evidence,
approval_present,
approval_required: config.require_human_approval,
content_hash,
decided_at,
anchor: PROMOTION_ANCHOR.to_string(),
}
}
}
fn status_for(kind: PolicyDecisionKind) -> PromotionStatus {
match kind {
PolicyDecisionKind::Allow | PolicyDecisionKind::AllowWithWarning => {
PromotionStatus::Promoted
}
PolicyDecisionKind::RequiresApproval => PromotionStatus::AwaitingApproval,
PolicyDecisionKind::Deny => PromotionStatus::Denied,
}
}
fn build_evidence(config: &PromotionGateConfig, metrics: &[(String, f64)]) -> Vec<MetricEvidence> {
config
.thresholds
.iter()
.map(|t| {
let measured = metrics
.iter()
.find(|(name, _)| name == &t.name)
.map(|(_, v)| *v);
let passed = measured.map(|v| v >= t.min_value).unwrap_or(false);
MetricEvidence {
name: t.name.clone(),
min_value: t.min_value,
measured_value: measured,
passed,
}
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn compute_content_hash(
agent_id: &str,
champion_version_id: Option<&str>,
challenger_version_id: &str,
decision_kind: PolicyDecisionKind,
status: PromotionStatus,
evidence: &[MetricEvidence],
approval_present: bool,
approval_required: bool,
) -> String {
#[derive(Serialize)]
struct HashableProjection<'a> {
agent_id: &'a str,
champion_version_id: Option<&'a str>,
challenger_version_id: &'a str,
decision_kind: PolicyDecisionKind,
status: &'a str,
evidence: &'a [MetricEvidence],
approval_present: bool,
approval_required: bool,
}
let projection = HashableProjection {
agent_id,
champion_version_id,
challenger_version_id,
decision_kind,
status: status.as_str(),
evidence,
approval_present,
approval_required,
};
let bytes = serde_json::to_vec(&projection).expect("projection must serialise");
let digest = Sha256::digest(&bytes);
hex::encode(digest)
}
#[cfg(test)]
mod tests {
use super::*;
fn ts() -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid timestamp")
}
fn config(require_approval: bool) -> PromotionGateConfig {
PromotionGateConfig {
thresholds: vec![
MetricThreshold::new("eval_pass_rate", 0.90),
MetricThreshold::new("bench_trust_score", 0.70),
],
require_human_approval: require_approval,
}
}
#[test]
fn empty_thresholds_deny_by_default() {
let gate = PromotionGate;
let cfg = PromotionGateConfig::default();
let decision = gate.evaluate(&cfg, &[("eval_pass_rate".into(), 0.99)], true);
assert!(decision.is_denied());
assert!(decision.reason.contains("no metric thresholds"));
}
#[test]
fn below_threshold_is_denied() {
let gate = PromotionGate;
let cfg = config(true);
let metrics = vec![
("eval_pass_rate".to_string(), 0.95),
("bench_trust_score".to_string(), 0.50),
];
let decision = gate.evaluate(&cfg, &metrics, true);
assert!(decision.is_denied());
assert!(decision.reason.contains("bench_trust_score"));
}
#[test]
fn missing_metric_is_treated_as_fail() {
let gate = PromotionGate;
let cfg = config(false);
let metrics = vec![("eval_pass_rate".to_string(), 0.95)];
let decision = gate.evaluate(&cfg, &metrics, false);
assert!(decision.is_denied());
}
#[test]
fn cleared_thresholds_without_approval_requires_approval() {
let gate = PromotionGate;
let cfg = config(true);
let metrics = vec![
("eval_pass_rate".to_string(), 0.95),
("bench_trust_score".to_string(), 0.80),
];
let decision = gate.evaluate(&cfg, &metrics, false);
assert!(decision.needs_approval());
}
#[test]
fn cleared_thresholds_with_approval_is_allowed() {
let gate = PromotionGate;
let cfg = config(true);
let metrics = vec![
("eval_pass_rate".to_string(), 0.95),
("bench_trust_score".to_string(), 0.80),
];
let decision = gate.evaluate(&cfg, &metrics, true);
assert!(decision.is_allowed());
}
#[test]
fn approval_not_required_allows_on_metrics_alone() {
let gate = PromotionGate;
let cfg = config(false);
let metrics = vec![
("eval_pass_rate".to_string(), 0.95),
("bench_trust_score".to_string(), 0.80),
];
let decision = gate.evaluate(&cfg, &metrics, false);
assert!(decision.is_allowed());
}
#[test]
fn threshold_is_inclusive_floor() {
let gate = PromotionGate;
let cfg = PromotionGateConfig {
thresholds: vec![MetricThreshold::new("eval_pass_rate", 0.90)],
require_human_approval: false,
};
let decision = gate.evaluate(&cfg, &[("eval_pass_rate".into(), 0.90)], false);
assert!(decision.is_allowed());
}
#[test]
fn decide_builds_record_with_status_and_hash() {
let gate = PromotionGate;
let cfg = config(true);
let metrics = vec![
("eval_pass_rate".to_string(), 0.95),
("bench_trust_score".to_string(), 0.80),
];
let decision = gate.evaluate(&cfg, &metrics, true);
let record = gate.decide(
"prm_test_001",
"agt_demo",
Some("agv_champion".into()),
"agv_challenger",
&cfg,
&metrics,
true,
&decision,
ts(),
);
assert_eq!(record.status, PromotionStatus::Promoted);
assert_eq!(record.decision_kind, PolicyDecisionKind::Allow);
assert_eq!(record.metric_evidence.len(), 2);
assert!(record.metric_evidence.iter().all(|e| e.passed));
assert_eq!(record.content_hash.len(), 64);
assert!(record.verify_hash());
}
#[test]
fn denied_decide_records_denied_status() {
let gate = PromotionGate;
let cfg = config(true);
let metrics = vec![
("eval_pass_rate".to_string(), 0.50),
("bench_trust_score".to_string(), 0.80),
];
let decision = gate.evaluate(&cfg, &metrics, true);
let record = gate.decide(
"prm_test_002",
"agt_demo",
None,
"agv_challenger",
&cfg,
&metrics,
true,
&decision,
ts(),
);
assert_eq!(record.status, PromotionStatus::Denied);
assert!(record.verify_hash());
}
#[test]
fn audit_details_round_trip_preserves_record() {
let gate = PromotionGate;
let cfg = config(false);
let metrics = vec![
("eval_pass_rate".to_string(), 0.95),
("bench_trust_score".to_string(), 0.80),
];
let decision = gate.evaluate(&cfg, &metrics, false);
let record = gate.decide(
"prm_test_003",
"agt_demo",
Some("agv_champion".into()),
"agv_challenger",
&cfg,
&metrics,
false,
&decision,
ts(),
);
let details = record.to_audit_details();
let parsed = PromotionDecision::from_audit_details(&details).expect("round-trip");
assert_eq!(parsed, record);
assert!(parsed.verify_hash());
}
#[test]
fn tampering_with_status_breaks_hash() {
let gate = PromotionGate;
let cfg = config(false);
let metrics = vec![
("eval_pass_rate".to_string(), 0.95),
("bench_trust_score".to_string(), 0.80),
];
let decision = gate.evaluate(&cfg, &metrics, false);
let mut record = gate.decide(
"prm_test_004",
"agt_demo",
None,
"agv_challenger",
&cfg,
&metrics,
false,
&decision,
ts(),
);
record.status = PromotionStatus::Denied;
assert!(!record.verify_hash());
}
#[test]
fn status_serialises_snake_case() {
for (status, expected) in [
(PromotionStatus::Shadow, "\"shadow\""),
(PromotionStatus::Promoted, "\"promoted\""),
(PromotionStatus::Denied, "\"denied\""),
(PromotionStatus::AwaitingApproval, "\"awaiting_approval\""),
] {
assert_eq!(serde_json::to_string(&status).unwrap(), expected);
}
}
#[test]
fn default_status_is_shadow() {
assert_eq!(PromotionStatus::default(), PromotionStatus::Shadow);
}
}