Skip to main content

fd_policy/
promotion.rs

1//! Champion-challenger promotion gate.
2//!
3//! A registered model / prompt / tool **version** (the *challenger*) cannot
4//! replace the live version (the *champion*) until it clears a deterministic
5//! gate: a set of configurable metric thresholds **and** a required human
6//! approval. Until it does, the challenger stays in **shadow**.
7//!
8//! The gate emits its verdict through the **same** [`PolicyDecision`] channel
9//! every other gate in this crate uses — deny-by-default, with the decision +
10//! evidence written to the immutable audit trail. This is a new *rule source*,
11//! not a parallel decision channel:
12//!
13//! - metrics below threshold → [`PolicyDecisionKind::Deny`] (stays shadow).
14//! - metrics clear threshold but no approval → [`PolicyDecisionKind::RequiresApproval`].
15//! - metrics clear threshold **and** approved → [`PolicyDecisionKind::Allow`] (promote).
16//! - any other state (no config, no metrics) → deny-by-default.
17//!
18//! The structured [`PromotionDecision`] record mirrors the
19//! [`crate::routing::RoutingDecision`] shape: it carries the evidence, a
20//! content-addressed hash for tamper-evidence, and `to_audit_details` /
21//! `from_audit_details` so it round-trips through `audit_events.details`
22//! without a parallel store.
23
24use chrono::{DateTime, Utc};
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27
28use crate::decision::{PolicyDecision, PolicyDecisionKind};
29
30/// Stable anchor recorded on every decision so audit consumers can cite the
31/// methodology root without re-reading docstrings.
32pub const PROMOTION_ANCHOR: &str = "champion-challenger";
33
34/// Lifecycle state of a challenger version relative to its champion.
35///
36/// Default is [`PromotionStatus::Shadow`] — a freshly registered version is
37/// never live until it explicitly clears the gate.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
39#[serde(rename_all = "snake_case")]
40pub enum PromotionStatus {
41    /// Registered but not serving traffic. The deny-by-default state.
42    #[default]
43    Shadow,
44    /// Cleared the gate and replaced the champion.
45    Promoted,
46    /// Evaluated and explicitly denied — stays shadow, but the operator has
47    /// a recorded reason (failed thresholds).
48    Denied,
49    /// Cleared thresholds but is waiting on the required human approval.
50    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/// A single configurable metric threshold the challenger must clear.
65///
66/// `min_value` is an inclusive floor: the measured value must be
67/// `>= min_value` for the metric to pass. Higher-is-better is the only
68/// supported direction — callers that need lower-is-better (e.g. cost,
69/// latency) pass a negated metric or a reciprocal so the floor semantics
70/// hold. Keeping a single direction makes the gate trivially deterministic.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct MetricThreshold {
73    /// Metric name, e.g. `eval_pass_rate`, `bench_trust_score`.
74    pub name: String,
75    /// Inclusive floor the measured value must reach.
76    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/// Configuration for a promotion gate: the thresholds the challenger must
89/// clear plus whether a human approval is required on top.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct PromotionGateConfig {
92    /// Metric thresholds, all of which must pass. An **empty** threshold list
93    /// is treated as "no automatic signal" and the gate denies by default —
94    /// a challenger never auto-promotes on zero evidence.
95    pub thresholds: Vec<MetricThreshold>,
96    /// When true, clearing the thresholds is necessary but not sufficient:
97    /// the gate returns `RequiresApproval` until a human approval is present.
98    pub require_human_approval: bool,
99}
100
101impl Default for PromotionGateConfig {
102    fn default() -> Self {
103        // Deny-by-default posture: no thresholds configured means nothing can
104        // auto-promote, and approval is required.
105        Self {
106            thresholds: Vec::new(),
107            require_human_approval: true,
108        }
109    }
110}
111
112/// The per-metric outcome recorded as evidence on the decision.
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct MetricEvidence {
115    pub name: String,
116    /// The configured floor.
117    pub min_value: f64,
118    /// The measured value, or `None` when the challenger did not report it
119    /// (a missing metric is a hard fail — the gate cannot assume success).
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub measured_value: Option<f64>,
122    /// Whether this metric cleared its floor.
123    pub passed: bool,
124}
125
126/// Structured promotion-gate decision written to the immutable audit trail.
127///
128/// Immutable once written; the `content_hash` lets an auditor verify the
129/// record was not tampered after the fact. Mirrors the
130/// [`crate::routing::RoutingDecision`] shape so the audit-read path is
131/// identical.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct PromotionDecision {
134    /// `prm_*` ULID; unique per record.
135    pub id: String,
136    /// Agent whose champion is being challenged.
137    pub agent_id: String,
138    /// The live champion version id (may be `None` for the very first
139    /// promotion, where there is no incumbent).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub champion_version_id: Option<String>,
142    /// The challenger version id under evaluation.
143    pub challenger_version_id: String,
144    /// The resulting policy-decision kind — the SAME enum every gate uses.
145    pub decision_kind: PolicyDecisionKind,
146    /// The resulting lifecycle status.
147    pub status: PromotionStatus,
148    /// Operator-readable explanation.
149    pub reason: String,
150    /// Per-metric evidence, in config order.
151    pub metric_evidence: Vec<MetricEvidence>,
152    /// Whether a human approval was present at evaluation time.
153    pub approval_present: bool,
154    /// Whether the gate config required an approval.
155    pub approval_required: bool,
156    /// SHA-256 over a stable projection of the structural fields (everything
157    /// except `decided_at` and `content_hash` itself).
158    pub content_hash: String,
159    /// Server-generated wall-clock UTC instant.
160    pub decided_at: DateTime<Utc>,
161    /// Stable methodology anchor.
162    #[serde(default = "default_anchor")]
163    pub anchor: String,
164}
165
166fn default_anchor() -> String {
167    PROMOTION_ANCHOR.to_string()
168}
169
170impl PromotionDecision {
171    /// Serialize into the JSON shape that goes into `audit_events.details`.
172    pub fn to_audit_details(&self) -> serde_json::Value {
173        serde_json::to_value(self).expect("PromotionDecision must always serialise")
174    }
175
176    /// Parse back out of an `audit_events.details` blob. Used by the gateway
177    /// `/v1/promotions/{agent_id}` read endpoint and the fd-evals replay.
178    pub fn from_audit_details(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
179        serde_json::from_value(value.clone())
180    }
181
182    /// True iff the stored `content_hash` still matches a re-computation from
183    /// the structural fields. Audit consumers use this to detect tampering.
184    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/// The champion-challenger promotion gate. Pure — `evaluate` has no I/O and
199/// no clock; the caller stamps `id` + `decided_at` and writes the audit row.
200#[derive(Debug, Clone, Default)]
201pub struct PromotionGate;
202
203impl PromotionGate {
204    /// Evaluate a challenger against the gate config + measured metrics.
205    ///
206    /// Returns a [`PolicyDecision`] — the same deny-by-default primitive every
207    /// gate emits — so the caller dispatches it through the existing audit +
208    /// approval path with no special-casing.
209    ///
210    /// Decision table:
211    /// - empty thresholds → **deny** (no evidence ⇒ no auto-promotion).
212    /// - any threshold fails (or its metric is missing) → **deny**.
213    /// - all thresholds pass, approval required but absent → **requires_approval**.
214    /// - all thresholds pass, (approval not required, or present) → **allow**.
215    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    /// Build the full structured [`PromotionDecision`] audit record from an
252    /// evaluation. The `decision` passed in is the output of [`Self::evaluate`]
253    /// — the caller threads it so the audit record and the dispatched policy
254    /// decision can never disagree.
255    #[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
300/// Map a policy-decision kind onto the promotion lifecycle status.
301fn 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
311/// Build per-metric evidence in config order. A metric missing from the
312/// measured set is recorded with `measured_value = None` and `passed = false`
313/// — the gate cannot assume an unreported metric succeeded.
314fn 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/// Deterministic SHA-256 over a stable projection. Single source so the
335/// fd-evals replay regression catches any drift.
336#[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        // pass-rate clears but trust score is below floor.
405        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        // bench_trust_score not reported at all.
419        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        // exactly at the floor passes.
468        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        // Flip the status without recomputing the hash — simulates a
571        // tampered audit row.
572        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}