1use anyhow::Result;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Map, Value};
10use thiserror::Error;
11use uuid::Uuid;
12
13#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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 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}