fleetforge_changeops/
lib.rs

1//! ChangeOps release gating engine: evaluates diffs, coverage, and eval signals
2//! to decide whether a change may ship automatically, requires follow-up, or
3//! must be blocked. Designed to be deterministic so gating decisions can be
4//! replayed and exported as artifacts.
5
6use anyhow::Result;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Map, Value};
10use thiserror::Error;
11use uuid::Uuid;
12
13/// Primary effect returned by the gating engine.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ChangeDecisionEffect {
17    Allow,
18    FollowUp,
19    Deny,
20}
21
22impl ChangeDecisionEffect {
23    pub fn as_str(self) -> &'static str {
24        match self {
25            ChangeDecisionEffect::Allow => "allow",
26            ChangeDecisionEffect::FollowUp => "follow_up",
27            ChangeDecisionEffect::Deny => "deny",
28        }
29    }
30
31    fn raise(self, next: ChangeDecisionEffect) -> ChangeDecisionEffect {
32        use ChangeDecisionEffect::*;
33        match (self, next) {
34            (Deny, _) | (_, Deny) => Deny,
35            (FollowUp, _) | (_, FollowUp) => FollowUp,
36            _ => Allow,
37        }
38    }
39}
40
41/// Summary of a single diff considered by the gate.
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct ChangeDiff {
44    pub path: String,
45    #[serde(default)]
46    pub lines_added: u32,
47    #[serde(default)]
48    pub lines_deleted: u32,
49    #[serde(default)]
50    pub novelty_score: Option<f64>,
51    #[serde(default)]
52    pub risk_tags: Vec<String>,
53    #[serde(default)]
54    pub component: Option<String>,
55}
56
57/// Aggregated coverage information per component or file group.
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct CoverageComponent {
60    pub name: String,
61    #[serde(default)]
62    pub coverage_ratio: Option<f64>,
63    #[serde(default)]
64    pub required_ratio: Option<f64>,
65}
66
67/// Coverage report derived from telemetry or test runs.
68#[derive(Debug, Clone, Serialize, Deserialize, Default)]
69pub struct CoverageReport {
70    #[serde(default)]
71    pub overall_ratio: Option<f64>,
72    #[serde(default)]
73    pub minimum_ratio: Option<f64>,
74    #[serde(default)]
75    pub components: Vec<CoverageComponent>,
76}
77
78/// Direction for metric thresholds.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "snake_case")]
81pub enum MetricDirection {
82    HigherIsBetter,
83    LowerIsBetter,
84}
85
86impl Default for MetricDirection {
87    fn default() -> Self {
88        MetricDirection::HigherIsBetter
89    }
90}
91
92/// Evaluation metric emitted by change validation (eval packs, smoke tests, etc.).
93#[derive(Debug, Clone, Serialize, Deserialize, Default)]
94pub struct EvalMetric {
95    pub name: String,
96    #[serde(default)]
97    pub slug: Option<String>,
98    pub score: f64,
99    #[serde(default)]
100    pub threshold: Option<f64>,
101    #[serde(default)]
102    pub direction: MetricDirection,
103    #[serde(default)]
104    pub weight: Option<f64>,
105}
106
107/// Budget or cost signal derived from the change (e.g. projected spend).
108#[derive(Debug, Clone, Serialize, Deserialize, Default)]
109pub struct BudgetSignal {
110    pub name: String,
111    pub actual: f64,
112    #[serde(default)]
113    pub limit: Option<f64>,
114}
115
116/// Inputs considered by the gating engine.
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct ChangeGateRequest {
119    pub change_id: String,
120    #[serde(default)]
121    pub gate_id: Option<Uuid>,
122    #[serde(default)]
123    pub revision: Option<String>,
124    #[serde(default)]
125    pub diffs: Vec<ChangeDiff>,
126    #[serde(default)]
127    pub coverage: CoverageReport,
128    #[serde(default)]
129    pub evals: Vec<EvalMetric>,
130    #[serde(default)]
131    pub budgets: Vec<BudgetSignal>,
132    #[serde(default)]
133    pub telemetry: Value,
134    #[serde(default)]
135    pub metadata: Value,
136    #[serde(default)]
137    pub actor: Option<String>,
138}
139
140/// Suggested follow-up action when the gate cannot auto-approve.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct FollowupAction {
143    pub slug: String,
144    pub description: String,
145    #[serde(default)]
146    pub recommended: bool,
147}
148
149impl FollowupAction {
150    pub fn recommended(slug: impl Into<String>, description: impl Into<String>) -> Self {
151        Self {
152            slug: slug.into(),
153            description: description.into(),
154            recommended: true,
155        }
156    }
157
158    pub fn optional(slug: impl Into<String>, description: impl Into<String>) -> Self {
159        Self {
160            slug: slug.into(),
161            description: description.into(),
162            recommended: false,
163        }
164    }
165}
166
167/// Scorecard summarising the inputs considered while evaluating the gate.
168#[derive(Debug, Clone, Serialize, Deserialize, Default)]
169pub struct DecisionScorecard {
170    pub novelty: NoveltySummary,
171    pub coverage: CoverageSummary,
172    pub evals: EvalSummary,
173    pub budgets: BudgetSummary,
174    #[serde(default)]
175    pub telemetry: Value,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, Default)]
179pub struct NoveltySummary {
180    pub max_novelty: f64,
181    pub avg_novelty: f64,
182    pub high_risk_paths: Vec<String>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, Default)]
186pub struct CoverageSummary {
187    pub overall_ratio: Option<f64>,
188    pub components_below_threshold: Vec<String>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, Default)]
192pub struct EvalSummary {
193    pub failing_metrics: Vec<String>,
194    pub attention_metrics: Vec<String>,
195    pub score_per_token: Option<f64>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, Default)]
199pub struct BudgetSummary {
200    pub breaches: Vec<String>,
201    pub near_limits: Vec<String>,
202}
203
204/// Final decision payload from the ChangeOps gate.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct ChangeGateDecision {
207    pub effect: ChangeDecisionEffect,
208    pub reasons: Vec<String>,
209    #[serde(default)]
210    pub followups: Vec<FollowupAction>,
211    pub scorecard: DecisionScorecard,
212    pub decided_at: DateTime<Utc>,
213    #[serde(default)]
214    pub metadata: Value,
215}
216
217impl ChangeGateDecision {
218    pub fn to_value(&self, request: &ChangeGateRequest) -> Value {
219        json!({
220            "change_id": request.change_id,
221            "gate_id": request.gate_id,
222            "revision": request.revision,
223            "effect": self.effect,
224            "reasons": self.reasons,
225            "followups": self.followups,
226            "scorecard": self.scorecard,
227            "decided_at": self.decided_at,
228            "metadata": self.metadata,
229            "telemetry": request.telemetry,
230            "diffs": request.diffs,
231            "evals": request.evals,
232            "budgets": request.budgets,
233            "coverage": request.coverage,
234        })
235    }
236}
237
238/// Change follow-up request (manual acknowledgement).
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ChangeFollowupRequest {
241    pub gate_id: Uuid,
242    pub actor: String,
243    #[serde(default)]
244    pub outcome: FollowupOutcome,
245    #[serde(default)]
246    pub note: Option<String>,
247    #[serde(default)]
248    pub details: Value,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
252#[serde(rename_all = "snake_case")]
253pub enum FollowupOutcome {
254    Approved,
255    NeedsChanges,
256    Deferred,
257}
258
259impl Default for FollowupOutcome {
260    fn default() -> Self {
261        FollowupOutcome::Approved
262    }
263}
264
265impl FollowupOutcome {
266    pub fn as_str(&self) -> &'static str {
267        match self {
268            FollowupOutcome::Approved => "approved",
269            FollowupOutcome::NeedsChanges => "needs_changes",
270            FollowupOutcome::Deferred => "deferred",
271        }
272    }
273
274    pub fn from_str(value: &str) -> Option<Self> {
275        match value {
276            "approved" => Some(FollowupOutcome::Approved),
277            "needs_changes" => Some(FollowupOutcome::NeedsChanges),
278            "deferred" => Some(FollowupOutcome::Deferred),
279            _ => None,
280        }
281    }
282}
283
284/// Recorded follow-up decision.
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ChangeFollowupRecord {
287    pub gate_id: Uuid,
288    pub actor: String,
289    pub outcome: FollowupOutcome,
290    #[serde(default)]
291    pub note: Option<String>,
292    #[serde(default)]
293    pub details: Value,
294    pub recorded_at: DateTime<Utc>,
295}
296
297/// Errors produced while evaluating the gate.
298#[derive(Debug, Error)]
299pub enum ChangeGateError {
300    #[error("change id is required")]
301    MissingChangeId,
302    #[error("no diffs, metrics, or telemetry provided")]
303    EmptySignalSet,
304}
305
306#[derive(Debug, Clone)]
307pub struct Thresholds {
308    pub deny_novelty: f64,
309    pub followup_novelty: f64,
310    pub required_coverage: f64,
311    pub coverage_soft_floor: f64,
312    pub eval_slack: f64,
313    pub budget_soft_multiplier: f64,
314    pub replay_drift_ratio: f64,
315}
316
317impl Default for Thresholds {
318    fn default() -> Self {
319        Self {
320            deny_novelty: 0.95,
321            followup_novelty: 0.8,
322            required_coverage: 0.75,
323            coverage_soft_floor: 0.6,
324            eval_slack: 0.05,
325            budget_soft_multiplier: 0.9,
326            replay_drift_ratio: 0.01,
327        }
328    }
329}
330
331#[derive(Debug, Default, Clone)]
332pub struct ChangeGateEngine {
333    thresholds: Thresholds,
334}
335
336impl ChangeGateEngine {
337    pub fn new(thresholds: Thresholds) -> Self {
338        Self { thresholds }
339    }
340
341    pub fn evaluate(&self, request: &ChangeGateRequest) -> Result<ChangeGateDecision> {
342        if request.change_id.trim().is_empty() {
343            return Err(ChangeGateError::MissingChangeId.into());
344        }
345
346        if request.diffs.is_empty()
347            && request.evals.is_empty()
348            && request.budgets.is_empty()
349            && request.telemetry == Value::Null
350        {
351            return Err(ChangeGateError::EmptySignalSet.into());
352        }
353
354        let mut effect = ChangeDecisionEffect::Allow;
355        let mut reasons: Vec<String> = Vec::new();
356        let mut followups: Vec<FollowupAction> = Vec::new();
357
358        let mut scorecard = DecisionScorecard::default();
359        scorecard.novelty =
360            self.evaluate_novelty(request, &mut effect, &mut reasons, &mut followups);
361        scorecard.coverage =
362            self.evaluate_coverage(request, &mut effect, &mut reasons, &mut followups);
363        scorecard.evals = self.evaluate_metrics(request, &mut effect, &mut reasons, &mut followups);
364        scorecard.budgets =
365            self.evaluate_budgets(request, &mut effect, &mut reasons, &mut followups);
366        self.evaluate_replays(
367            &request.telemetry,
368            &mut effect,
369            &mut reasons,
370            &mut followups,
371        );
372        self.evaluate_canary(
373            &request.telemetry,
374            &mut effect,
375            &mut reasons,
376            &mut followups,
377        );
378        scorecard.telemetry = request.telemetry.clone();
379
380        // Remove duplicate reasons while preserving insertion order.
381        let mut reason_set = std::collections::HashSet::new();
382        reasons.retain(|reason| reason_set.insert(reason.clone()));
383
384        Ok(ChangeGateDecision {
385            effect,
386            reasons,
387            followups,
388            scorecard,
389            decided_at: Utc::now(),
390            metadata: request.metadata.clone(),
391        })
392    }
393
394    fn evaluate_novelty(
395        &self,
396        request: &ChangeGateRequest,
397        effect: &mut ChangeDecisionEffect,
398        reasons: &mut Vec<String>,
399        followups: &mut Vec<FollowupAction>,
400    ) -> NoveltySummary {
401        let mut summary = NoveltySummary::default();
402        if request.diffs.is_empty() {
403            return summary;
404        }
405
406        let mut total = 0.0;
407        let mut count = 0.0;
408        let mut high_risk: Vec<String> = Vec::new();
409
410        for diff in &request.diffs {
411            let novelty = diff.novelty_score.unwrap_or_default().clamp(0.0, 1.0);
412            summary.max_novelty = summary.max_novelty.max(novelty);
413            total += novelty;
414            count += 1.0;
415
416            if novelty >= self.thresholds.deny_novelty {
417                *effect = effect.raise(ChangeDecisionEffect::Deny);
418                reasons.push(format!("novelty_high:{}", diff.path));
419                high_risk.push(diff.path.clone());
420            } else if novelty >= self.thresholds.followup_novelty {
421                *effect = effect.raise(ChangeDecisionEffect::FollowUp);
422                reasons.push(format!("novelty_review:{}", diff.path));
423                high_risk.push(diff.path.clone());
424            }
425
426            if diff
427                .risk_tags
428                .iter()
429                .any(|tag| matches!(tag.as_str(), "security" | "compliance" | "schema_break"))
430            {
431                *effect = effect.raise(ChangeDecisionEffect::FollowUp);
432                reasons.push(format!(
433                    "risk_tag:{}:{}",
434                    diff.path,
435                    diff.risk_tags.join(",")
436                ));
437                high_risk.push(diff.path.clone());
438            }
439        }
440
441        if count > 0.0 {
442            summary.avg_novelty = (total / count).clamp(0.0, 1.0);
443        }
444        summary.high_risk_paths = high_risk;
445
446        if summary.max_novelty >= self.thresholds.followup_novelty && followups.is_empty() {
447            followups.push(FollowupAction::recommended(
448                "novelty_review",
449                "Review high-novelty diffs with on-call approver",
450            ));
451        }
452
453        summary
454    }
455
456    fn evaluate_coverage(
457        &self,
458        request: &ChangeGateRequest,
459        effect: &mut ChangeDecisionEffect,
460        reasons: &mut Vec<String>,
461        followups: &mut Vec<FollowupAction>,
462    ) -> CoverageSummary {
463        let mut summary = CoverageSummary::default();
464        let coverage = &request.coverage;
465        summary.overall_ratio = coverage.overall_ratio;
466
467        let required = coverage
468            .minimum_ratio
469            .unwrap_or(self.thresholds.required_coverage);
470
471        for component in &coverage.components {
472            let ratio = component.coverage_ratio.unwrap_or(0.0);
473            let expected = component
474                .required_ratio
475                .unwrap_or(required)
476                .max(self.thresholds.coverage_soft_floor);
477            if ratio + f64::EPSILON < expected {
478                summary
479                    .components_below_threshold
480                    .push(component.name.clone());
481                *effect = effect.raise(ChangeDecisionEffect::FollowUp);
482                reasons.push(format!(
483                    "coverage_gap:{}:{:.2}->{:.2}",
484                    component.name, ratio, expected
485                ));
486            }
487        }
488
489        if let Some(overall) = coverage.overall_ratio {
490            if overall + f64::EPSILON < self.thresholds.coverage_soft_floor {
491                *effect = effect.raise(ChangeDecisionEffect::Deny);
492                reasons.push(format!(
493                    "coverage_overall_low:{:.2}",
494                    coverage.overall_ratio.unwrap_or_default()
495                ));
496            }
497        }
498
499        if !summary.components_below_threshold.is_empty() {
500            followups.push(FollowupAction::recommended(
501                "tests",
502                "Add or rerun coverage for touched components",
503            ));
504        }
505
506        summary
507    }
508
509    fn evaluate_metrics(
510        &self,
511        request: &ChangeGateRequest,
512        effect: &mut ChangeDecisionEffect,
513        reasons: &mut Vec<String>,
514        followups: &mut Vec<FollowupAction>,
515    ) -> EvalSummary {
516        let mut summary = EvalSummary::default();
517        if request.evals.is_empty() {
518            return summary;
519        }
520
521        for metric in &request.evals {
522            let slug = metric.slug.as_ref().unwrap_or(&metric.name);
523            if slug.eq_ignore_ascii_case("score_per_token") {
524                summary.score_per_token = Some(metric.score);
525            }
526
527            if let Some(threshold) = metric.threshold {
528                match metric.direction {
529                    MetricDirection::HigherIsBetter => {
530                        if metric.score + self.thresholds.eval_slack < threshold {
531                            *effect = effect.raise(ChangeDecisionEffect::Deny);
532                            summary
533                                .failing_metrics
534                                .push(format!("{}:{:.3}->{:.3}", slug, metric.score, threshold));
535                            reasons.push(format!("metric_below:{}", slug));
536                        } else if metric.score < threshold {
537                            summary
538                                .attention_metrics
539                                .push(format!("{}:{:.3}->{:.3}", slug, metric.score, threshold));
540                            *effect = effect.raise(ChangeDecisionEffect::FollowUp);
541                        }
542                    }
543                    MetricDirection::LowerIsBetter => {
544                        if metric.score > threshold + self.thresholds.eval_slack {
545                            *effect = effect.raise(ChangeDecisionEffect::Deny);
546                            summary
547                                .failing_metrics
548                                .push(format!("{}:{:.3}->{:.3}", slug, metric.score, threshold));
549                            reasons.push(format!("metric_above:{}", slug));
550                        } else if metric.score > threshold {
551                            summary
552                                .attention_metrics
553                                .push(format!("{}:{:.3}->{:.3}", slug, metric.score, threshold));
554                            *effect = effect.raise(ChangeDecisionEffect::FollowUp);
555                        }
556                    }
557                }
558            }
559        }
560
561        if !summary.failing_metrics.is_empty() {
562            followups.push(FollowupAction::recommended(
563                "evals",
564                "Investigate failing evaluation metrics",
565            ));
566        } else if !summary.attention_metrics.is_empty() {
567            followups.push(FollowupAction::optional(
568                "evals_watch",
569                "Monitor marginal evaluation metrics before shipping",
570            ));
571        }
572
573        summary
574    }
575
576    fn evaluate_budgets(
577        &self,
578        request: &ChangeGateRequest,
579        effect: &mut ChangeDecisionEffect,
580        reasons: &mut Vec<String>,
581        followups: &mut Vec<FollowupAction>,
582    ) -> BudgetSummary {
583        let mut summary = BudgetSummary::default();
584        for budget in &request.budgets {
585            if let Some(limit) = budget.limit {
586                if budget.actual > limit {
587                    *effect = effect.raise(ChangeDecisionEffect::Deny);
588                    summary
589                        .breaches
590                        .push(format!("{}:{:.2}>{:.2}", budget.name, budget.actual, limit));
591                    reasons.push(format!("budget_exceeded:{}", budget.name));
592                } else if budget.actual > limit * self.thresholds.budget_soft_multiplier {
593                    summary
594                        .near_limits
595                        .push(format!("{}:{:.2}>{:.2}", budget.name, budget.actual, limit));
596                    *effect = effect.raise(ChangeDecisionEffect::FollowUp);
597                }
598            }
599        }
600
601        if !summary.breaches.is_empty() {
602            followups.push(FollowupAction::recommended(
603                "budget",
604                "Reduce cost or adjust budget thresholds before deploy",
605            ));
606        } else if !summary.near_limits.is_empty() {
607            followups.push(FollowupAction::optional(
608                "budget_watch",
609                "Budget near limits; confirm with cost owner",
610            ));
611        }
612
613        summary
614    }
615
616    fn evaluate_replays(
617        &self,
618        telemetry: &Value,
619        effect: &mut ChangeDecisionEffect,
620        reasons: &mut Vec<String>,
621        followups: &mut Vec<FollowupAction>,
622    ) {
623        match telemetry.get("replays").and_then(Value::as_array) {
624            Some(replays) if !replays.is_empty() => {
625                for replay in replays {
626                    let name = replay
627                        .get("name")
628                        .and_then(Value::as_str)
629                        .unwrap_or("replay");
630                    let attestation_ok = replay
631                        .get("attestation_match")
632                        .and_then(Value::as_bool)
633                        .unwrap_or(false);
634                    if !attestation_ok {
635                        reasons.push(format!("replay_attestation_mismatch:{name}"));
636                        *effect = effect.raise(ChangeDecisionEffect::Deny);
637                    }
638                    if let Some(drift) = replay.get("token_drift").and_then(Value::as_f64) {
639                        if drift > self.thresholds.replay_drift_ratio + f64::EPSILON {
640                            reasons.push(format!("replay_drift_exceeded:{name}:{drift:.4}"));
641                            *effect = effect.raise(ChangeDecisionEffect::Deny);
642                        }
643                    }
644                    let tool_match = replay
645                        .get("tool_io_match")
646                        .and_then(Value::as_bool)
647                        .unwrap_or(true);
648                    if !tool_match {
649                        reasons.push(format!("replay_tool_io_mismatch:{name}"));
650                        *effect = effect.raise(ChangeDecisionEffect::Deny);
651                    }
652                }
653            }
654            _ => {
655                if !followups.iter().any(|f| f.slug == "replay_evidence") {
656                    followups.push(FollowupAction::recommended(
657                        "replay_evidence",
658                        "Attach deterministic replay results with attestation alignment",
659                    ));
660                }
661                reasons.push("replay_missing".into());
662                *effect = effect.raise(ChangeDecisionEffect::FollowUp);
663            }
664        }
665    }
666
667    fn evaluate_canary(
668        &self,
669        telemetry: &Value,
670        effect: &mut ChangeDecisionEffect,
671        reasons: &mut Vec<String>,
672        followups: &mut Vec<FollowupAction>,
673    ) {
674        match telemetry.get("canary").and_then(Value::as_array) {
675            Some(canary_runs) if !canary_runs.is_empty() => {
676                for entry in canary_runs {
677                    let name = entry
678                        .get("name")
679                        .and_then(Value::as_str)
680                        .unwrap_or("canary");
681                    let attestation_ok = entry
682                        .get("attestation_match")
683                        .and_then(Value::as_bool)
684                        .unwrap_or(false);
685                    if !attestation_ok {
686                        reasons.push(format!("canary_attestation_mismatch:{name}"));
687                        *effect = effect.raise(ChangeDecisionEffect::Deny);
688                    }
689                    let ids_present = entry
690                        .get("attestation_ids")
691                        .and_then(Value::as_array)
692                        .map(|arr| !arr.is_empty())
693                        .unwrap_or(false);
694                    if !ids_present {
695                        reasons.push(format!("canary_attestation_missing:{name}"));
696                        *effect = effect.raise(ChangeDecisionEffect::Deny);
697                    }
698                }
699            }
700            _ => {
701                if !followups.iter().any(|f| f.slug == "canary_attestations") {
702                    followups.push(FollowupAction::recommended(
703                        "canary_attestations",
704                        "Surface canary attestation set before promotion",
705                    ));
706                }
707                reasons.push("canary_missing".into());
708                *effect = effect.raise(ChangeDecisionEffect::FollowUp);
709            }
710        }
711    }
712}
713
714impl ChangeFollowupRecord {
715    pub fn to_value(&self) -> Value {
716        let mut map = Map::new();
717        map.insert("gate_id".into(), Value::String(self.gate_id.to_string()));
718        map.insert("actor".into(), Value::String(self.actor.clone()));
719        map.insert(
720            "outcome".into(),
721            Value::String(match self.outcome {
722                FollowupOutcome::Approved => "approved".into(),
723                FollowupOutcome::NeedsChanges => "needs_changes".into(),
724                FollowupOutcome::Deferred => "deferred".into(),
725            }),
726        );
727        if let Some(note) = &self.note {
728            map.insert("note".into(), Value::String(note.clone()));
729        }
730        map.insert("details".into(), self.details.clone());
731        map.insert(
732            "recorded_at".into(),
733            Value::String(self.recorded_at.to_rfc3339()),
734        );
735        Value::Object(map)
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    fn base_request() -> ChangeGateRequest {
744        ChangeGateRequest {
745            change_id: "change-test".into(),
746            diffs: vec![ChangeDiff {
747                path: "core/runtime/src/scheduler.rs".into(),
748                lines_added: 120,
749                lines_deleted: 2,
750                novelty_score: Some(0.42),
751                risk_tags: vec!["runtime".into()],
752                component: Some("runtime".into()),
753            }],
754            coverage: CoverageReport {
755                overall_ratio: Some(0.82),
756                minimum_ratio: Some(0.75),
757                components: vec![CoverageComponent {
758                    name: "runtime".into(),
759                    coverage_ratio: Some(0.8),
760                    required_ratio: Some(0.75),
761                }],
762            },
763            evals: vec![EvalMetric {
764                name: "shadow_replay".into(),
765                slug: Some("shadow_replay".into()),
766                score: 0.98,
767                threshold: Some(0.95),
768                direction: MetricDirection::HigherIsBetter,
769                weight: None,
770            }],
771            budgets: vec![BudgetSignal {
772                name: "score_per_token".into(),
773                actual: 0.0075,
774                limit: Some(0.01),
775            }],
776            telemetry: json!({
777                "deploy": {
778                    "window": "us-west",
779                    "service": "runtime"
780                },
781                "replays": [{
782                    "name": "shadow",
783                    "attestation_match": true,
784                    "token_drift": 0.0,
785                    "tool_io_match": true
786                }],
787                "canary": [{
788                    "name": "blue",
789                    "attestation_match": true,
790                    "attestation_ids": [Uuid::new_v4().to_string()]
791                }]
792            }),
793            metadata: json!({"branch": "feature/changeops"}),
794            ..Default::default()
795        }
796    }
797
798    #[test]
799    fn allows_when_within_thresholds() {
800        let engine = ChangeGateEngine::default();
801        let request = base_request();
802        let decision = engine.evaluate(&request).expect("decision");
803        assert_eq!(decision.effect, ChangeDecisionEffect::Allow);
804        assert!(decision.reasons.is_empty());
805        assert!(decision.followups.is_empty());
806        assert!(decision.scorecard.novelty.max_novelty > 0.0);
807    }
808
809    #[test]
810    fn escalates_for_high_novelty() {
811        let engine = ChangeGateEngine::default();
812        let mut request = base_request();
813        request.diffs[0].novelty_score = Some(0.9);
814        let decision = engine.evaluate(&request).expect("decision");
815        assert_eq!(decision.effect, ChangeDecisionEffect::FollowUp);
816        assert!(decision
817            .reasons
818            .iter()
819            .any(|reason| reason.contains("novelty_review")));
820        assert!(!decision.followups.is_empty());
821    }
822
823    #[test]
824    fn denies_for_eval_failures() {
825        let engine = ChangeGateEngine::default();
826        let mut request = base_request();
827        request.evals.push(EvalMetric {
828            name: "pii_blocker".into(),
829            slug: Some("pii_blocker".into()),
830            score: 0.2,
831            threshold: Some(0.9),
832            direction: MetricDirection::HigherIsBetter,
833            weight: None,
834        });
835        let decision = engine.evaluate(&request).expect("decision");
836        assert_eq!(decision.effect, ChangeDecisionEffect::Deny);
837        assert!(decision
838            .reasons
839            .iter()
840            .any(|reason| reason.contains("metric_below")));
841    }
842
843    #[test]
844    fn denies_for_replay_attestation_mismatch() {
845        let engine = ChangeGateEngine::default();
846        let mut request = base_request();
847        if let Some(replays) = request
848            .telemetry
849            .get_mut("replays")
850            .and_then(Value::as_array_mut)
851        {
852            if let Some(first) = replays.first_mut() {
853                first["attestation_match"] = Value::Bool(false);
854            }
855        }
856        let decision = engine.evaluate(&request).expect("decision");
857        assert_eq!(decision.effect, ChangeDecisionEffect::Deny);
858        assert!(decision
859            .reasons
860            .iter()
861            .any(|reason| reason.contains("replay_attestation_mismatch")));
862    }
863
864    #[test]
865    fn followup_when_replays_missing() {
866        let engine = ChangeGateEngine::default();
867        let mut request = base_request();
868        if let Value::Object(map) = &mut request.telemetry {
869            map.remove("replays");
870        }
871        let decision = engine.evaluate(&request).expect("decision");
872        assert_eq!(decision.effect, ChangeDecisionEffect::FollowUp);
873        assert!(decision
874            .reasons
875            .iter()
876            .any(|reason| reason == "replay_missing"));
877        assert!(decision
878            .followups
879            .iter()
880            .any(|f| f.slug == "replay_evidence"));
881    }
882
883    #[test]
884    fn denies_for_canary_attestation_mismatch() {
885        let engine = ChangeGateEngine::default();
886        let mut request = base_request();
887        if let Some(entries) = request
888            .telemetry
889            .get_mut("canary")
890            .and_then(Value::as_array_mut)
891        {
892            if let Some(first) = entries.first_mut() {
893                first["attestation_match"] = Value::Bool(false);
894            }
895        }
896        let decision = engine.evaluate(&request).expect("decision");
897        assert_eq!(decision.effect, ChangeDecisionEffect::Deny);
898        assert!(decision
899            .reasons
900            .iter()
901            .any(|reason| reason.contains("canary_attestation_mismatch")));
902    }
903
904    #[test]
905    fn followup_when_canary_missing() {
906        let engine = ChangeGateEngine::default();
907        let mut request = base_request();
908        if let Value::Object(map) = &mut request.telemetry {
909            map.remove("canary");
910        }
911        let decision = engine.evaluate(&request).expect("decision");
912        assert_eq!(decision.effect, ChangeDecisionEffect::FollowUp);
913        assert!(decision
914            .reasons
915            .iter()
916            .any(|reason| reason == "canary_missing"));
917        assert!(decision
918            .followups
919            .iter()
920            .any(|f| f.slug == "canary_attestations"));
921    }
922
923    #[test]
924    fn denies_when_signal_set_empty() {
925        let engine = ChangeGateEngine::default();
926        let request = ChangeGateRequest {
927            change_id: "empty".into(),
928            ..Default::default()
929        };
930        let err = engine.evaluate(&request).expect_err("should fail");
931        assert!(format!("{err}").contains("no diffs"));
932    }
933
934    #[test]
935    fn followup_outcome_roundtrip() {
936        let variants = [
937            FollowupOutcome::Approved,
938            FollowupOutcome::NeedsChanges,
939            FollowupOutcome::Deferred,
940        ];
941        for outcome in variants {
942            let label = outcome.as_str();
943            let parsed = FollowupOutcome::from_str(label).expect("round-trip");
944            assert_eq!(parsed, outcome);
945        }
946    }
947}