use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceStep {
pub tool: String,
pub ok: bool,
}
impl TraceStep {
pub fn ok(tool: impl Into<String>) -> Self {
Self {
tool: tool.into(),
ok: true,
}
}
pub fn failed(tool: impl Into<String>) -> Self {
Self {
tool: tool.into(),
ok: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TraceRule {
Precedes {
earlier: String,
later: String,
#[serde(default)]
name: Option<String>,
},
Never {
tool: String,
#[serde(default)]
name: Option<String>,
},
Eventually {
trigger: String,
required: String,
#[serde(default)]
name: Option<String>,
},
Until {
start: String,
forbidden: String,
release: String,
#[serde(default)]
name: Option<String>,
},
}
impl TraceRule {
pub fn label(&self) -> String {
match self {
Self::Precedes {
earlier,
later,
name,
} => name
.clone()
.unwrap_or_else(|| format!("{earlier}_precedes_{later}")),
Self::Never { tool, name } => name.clone().unwrap_or_else(|| format!("never_{tool}")),
Self::Eventually {
trigger,
required,
name,
} => name
.clone()
.unwrap_or_else(|| format!("{trigger}_eventually_{required}")),
Self::Until {
start,
forbidden,
release,
name,
} => name
.clone()
.unwrap_or_else(|| format!("{forbidden}_forbidden_between_{start}_and_{release}")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceViolation {
pub rule: String,
pub tool: String,
pub step: usize,
pub explanation: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceReport {
pub compliant: bool,
pub violations: Vec<TraceViolation>,
pub steps: usize,
}
impl TraceReport {
pub const fn evidence_tier(&self) -> crate::EvidenceTier {
crate::EvidenceTier::DecisionProcedure
}
}
fn succeeded_in(prefix: &[TraceStep], tool: &str) -> bool {
prefix.iter().any(|s| s.tool == tool && s.ok)
}
fn appears_in(prefix: &[TraceStep], tool: &str) -> bool {
prefix.iter().any(|s| s.tool == tool)
}
pub fn check_next(rules: &[TraceRule], prefix: &[TraceStep], next: &str) -> Vec<TraceViolation> {
let step = prefix.len();
let mut out = Vec::new();
for rule in rules {
match rule {
TraceRule::Precedes { earlier, later, .. }
if next == later && !succeeded_in(prefix, earlier) =>
{
let ran_but_failed = appears_in(prefix, earlier);
out.push(TraceViolation {
rule: rule.label(),
tool: next.to_string(),
step,
explanation: if ran_but_failed {
format!("`{next}` requires `{earlier}` to have succeeded first; it ran and failed")
} else {
format!("`{next}` requires `{earlier}` to have succeeded first; it has not run")
},
});
}
TraceRule::Never { tool, .. } if next == tool => {
out.push(TraceViolation {
rule: rule.label(),
tool: next.to_string(),
step,
explanation: format!("`{next}` may never run"),
});
}
TraceRule::Until {
start,
forbidden,
release,
..
} if next == forbidden && in_forbidden_window(prefix, start, release) => {
out.push(TraceViolation {
rule: rule.label(),
tool: next.to_string(),
step,
explanation: format!(
"`{next}` may not run after `{start}` until `{release}` has run"
),
});
}
_ => {}
}
}
out
}
fn in_forbidden_window(prefix: &[TraceStep], start: &str, release: &str) -> bool {
let last = |tool: &str| prefix.iter().rposition(|s| s.tool == tool && s.ok);
match (last(start), last(release)) {
(Some(s), Some(r)) => s > r,
(Some(_), None) => true,
(None, _) => false,
}
}
pub fn evaluate_trace(rules: &[TraceRule], trace: &[TraceStep]) -> TraceReport {
let mut violations = Vec::new();
for (i, step) in trace.iter().enumerate() {
violations.extend(check_next(rules, &trace[..i], &step.tool));
}
for rule in rules {
if let TraceRule::Eventually {
trigger, required, ..
} = rule
{
let triggered_at: Vec<usize> = trace
.iter()
.enumerate()
.filter(|(_, s)| &s.tool == trigger && s.ok)
.map(|(i, _)| i)
.collect();
for at in triggered_at {
let discharged = trace[at + 1..].iter().any(|s| &s.tool == required && s.ok);
if !discharged {
violations.push(TraceViolation {
rule: rule.label(),
tool: trigger.clone(),
step: at,
explanation: format!(
"`{trigger}` ran at step {at} but `{required}` never followed"
),
});
}
}
}
}
TraceReport {
compliant: violations.is_empty(),
steps: trace.len(),
violations,
}
}
pub fn trace_from_events(events: &[serde_json::Value]) -> Vec<TraceStep> {
events
.iter()
.filter_map(|e| {
let kind = e.get("kind")?.as_str()?;
let ok = match kind {
"action_succeeded" => true,
"action_failed" => false,
_ => return None,
};
let tool = e
.get("data")
.and_then(|d| d.get("tool"))
.and_then(|t| t.as_str())
.or_else(|| e.get("action_id").and_then(|a| a.as_str()))?
.to_string();
Some(TraceStep { tool, ok })
})
.collect()
}
pub fn governed_tools(rules: &[TraceRule]) -> HashSet<String> {
let mut out = HashSet::new();
for rule in rules {
match rule {
TraceRule::Precedes { earlier, later, .. } => {
out.insert(earlier.clone());
out.insert(later.clone());
}
TraceRule::Never { tool, .. } => {
out.insert(tool.clone());
}
TraceRule::Eventually {
trigger, required, ..
} => {
out.insert(trigger.clone());
out.insert(required.clone());
}
TraceRule::Until {
start,
forbidden,
release,
..
} => {
out.insert(start.clone());
out.insert(forbidden.clone());
out.insert(release.clone());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn precedes(earlier: &str, later: &str) -> TraceRule {
TraceRule::Precedes {
earlier: earlier.into(),
later: later.into(),
name: None,
}
}
#[test]
fn deploy_without_test_is_blocked_online_and_offline() {
let rules = [precedes("test", "deploy")];
let prefix = [TraceStep::ok("build")];
let online = check_next(&rules, &prefix, "deploy");
assert_eq!(online.len(), 1, "{online:?}");
assert!(online[0].explanation.contains("has not run"));
let report = evaluate_trace(&rules, &[TraceStep::ok("build"), TraceStep::ok("deploy")]);
assert!(!report.compliant);
assert_eq!(report.violations.len(), 1);
}
#[test]
fn deploy_after_a_successful_test_is_allowed() {
let rules = [precedes("test", "deploy")];
let trace = [TraceStep::ok("test"), TraceStep::ok("deploy")];
assert!(check_next(&rules, &trace[..1], "deploy").is_empty());
assert!(evaluate_trace(&rules, &trace).compliant);
}
#[test]
fn a_failed_test_does_not_authorize_the_deploy() {
let rules = [precedes("test", "deploy")];
let prefix = [TraceStep::failed("test")];
let v = check_next(&rules, &prefix, "deploy");
assert_eq!(v.len(), 1);
assert!(
v[0].explanation.contains("ran and failed"),
"the message must distinguish this from never having run: {:?}",
v[0].explanation
);
}
#[test]
fn an_unrelated_tool_is_never_blocked() {
let rules = [precedes("test", "deploy")];
assert!(check_next(&rules, &[], "format").is_empty());
}
#[test]
fn never_blocks_on_the_first_call() {
let rules = [TraceRule::Never {
tool: "rm_rf".into(),
name: None,
}];
assert_eq!(check_next(&rules, &[], "rm_rf").len(), 1);
assert!(!evaluate_trace(&rules, &[TraceStep::ok("rm_rf")]).compliant);
}
#[test]
fn an_undischarged_obligation_fails_the_completed_trace() {
let rules = [TraceRule::Eventually {
trigger: "acquire_lease".into(),
required: "release_lease".into(),
name: None,
}];
let report = evaluate_trace(&rules, &[TraceStep::ok("acquire_lease")]);
assert!(!report.compliant);
assert!(report.violations[0].explanation.contains("never followed"));
}
#[test]
fn a_discharged_obligation_passes() {
let rules = [TraceRule::Eventually {
trigger: "acquire_lease".into(),
required: "release_lease".into(),
name: None,
}];
let trace = [
TraceStep::ok("acquire_lease"),
TraceStep::ok("write_file"),
TraceStep::ok("release_lease"),
];
assert!(evaluate_trace(&rules, &trace).compliant);
}
#[test]
fn liveness_never_blocks_a_prefix() {
let rules = [TraceRule::Eventually {
trigger: "acquire_lease".into(),
required: "release_lease".into(),
name: None,
}];
let prefix = [TraceStep::ok("acquire_lease")];
assert!(check_next(&rules, &prefix, "anything").is_empty());
}
#[test]
fn a_second_trigger_needs_its_own_discharge() {
let rules = [TraceRule::Eventually {
trigger: "acquire_lease".into(),
required: "release_lease".into(),
name: None,
}];
let trace = [
TraceStep::ok("acquire_lease"),
TraceStep::ok("release_lease"),
TraceStep::ok("acquire_lease"),
];
let report = evaluate_trace(&rules, &trace);
assert!(!report.compliant);
assert_eq!(report.violations[0].step, 2);
}
fn until_rule() -> TraceRule {
TraceRule::Until {
start: "fetch_url".into(),
forbidden: "write_file".into(),
release: "approval".into(),
name: None,
}
}
#[test]
fn the_window_opens_at_start_and_closes_at_release() {
let rules = [until_rule()];
assert!(check_next(&rules, &[], "write_file").is_empty());
let inside = [TraceStep::ok("fetch_url")];
assert_eq!(check_next(&rules, &inside, "write_file").len(), 1);
let released = [TraceStep::ok("fetch_url"), TraceStep::ok("approval")];
assert!(check_next(&rules, &released, "write_file").is_empty());
}
#[test]
fn a_later_start_reopens_the_window() {
let rules = [until_rule()];
let prefix = [
TraceStep::ok("fetch_url"),
TraceStep::ok("approval"),
TraceStep::ok("fetch_url"),
];
assert_eq!(check_next(&rules, &prefix, "write_file").len(), 1);
}
#[test]
fn a_failed_release_does_not_close_the_window() {
let rules = [until_rule()];
let prefix = [TraceStep::ok("fetch_url"), TraceStep::failed("approval")];
assert_eq!(check_next(&rules, &prefix, "write_file").len(), 1);
}
#[test]
fn offline_scoring_agrees_with_the_online_gate() {
let rules = [precedes("test", "deploy"), until_rule()];
let trace = [
TraceStep::ok("fetch_url"),
TraceStep::ok("write_file"), TraceStep::ok("deploy"), ];
let report = evaluate_trace(&rules, &trace);
assert_eq!(report.violations.len(), 2, "{report:?}");
assert_eq!(report.steps, 3);
assert_eq!(check_next(&rules, &trace[..1], "write_file").len(), 1);
assert_eq!(check_next(&rules, &trace[..2], "deploy").len(), 1);
}
#[test]
fn an_empty_rule_set_is_vacuously_compliant() {
let report = evaluate_trace(&[], &[TraceStep::ok("anything")]);
assert!(report.compliant);
assert_eq!(report.steps, 1);
}
#[test]
fn governed_tools_lists_every_tool_a_rule_mentions() {
let rules = [precedes("test", "deploy"), until_rule()];
let tools = governed_tools(&rules);
for expected in ["test", "deploy", "fetch_url", "write_file", "approval"] {
assert!(
tools.contains(expected),
"{expected} missing from {tools:?}"
);
}
}
#[test]
fn a_trace_is_built_from_event_log_records() {
let events: Vec<serde_json::Value> = vec![
serde_json::json!({"kind": "action_executing", "data": {"tool": "test"}}),
serde_json::json!({"kind": "action_succeeded", "data": {"tool": "test"}}),
serde_json::json!({"kind": "action_failed", "data": {"tool": "deploy"}}),
serde_json::json!({"kind": "state_changed", "data": {}}),
serde_json::json!({"kind": "action_succeeded", "action_id": "check:lint"}),
];
let trace = trace_from_events(&events);
assert_eq!(
trace,
vec![
TraceStep::ok("test"),
TraceStep::failed("deploy"),
TraceStep::ok("check:lint"),
],
"only executed calls become steps, and `action_executing` must not \
double-count the call its `action_succeeded` already records"
);
}
#[test]
fn a_violating_run_is_detected_from_its_event_log() {
let rules = [precedes("test", "deploy")];
let events: Vec<serde_json::Value> = vec![
serde_json::json!({"kind": "action_succeeded", "data": {"tool": "build"}}),
serde_json::json!({"kind": "action_succeeded", "data": {"tool": "deploy"}}),
];
let report = evaluate_trace(&rules, &trace_from_events(&events));
assert!(!report.compliant);
assert_eq!(report.violations[0].tool, "deploy");
}
#[test]
fn rules_round_trip_through_json() {
let rules = vec![precedes("test", "deploy"), until_rule()];
let json = serde_json::to_string(&rules).unwrap();
let back: Vec<TraceRule> = serde_json::from_str(&json).unwrap();
assert_eq!(rules, back);
}
}
#[derive(Debug, Clone, Default)]
pub struct TraceGate {
rules: Vec<TraceRule>,
trace: Vec<TraceStep>,
governed: HashSet<String>,
}
impl TraceGate {
pub fn new(rules: Vec<TraceRule>) -> Self {
let governed = governed_tools(&rules);
Self {
rules,
trace: Vec::new(),
governed,
}
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn check(&self, tool: &str) -> Vec<TraceViolation> {
if self.rules.is_empty() || !self.governed.contains(tool) {
return Vec::new();
}
check_next(&self.rules, &self.trace, tool)
}
pub fn record(&mut self, tool: &str, ok: bool) {
self.trace.push(TraceStep {
tool: tool.to_string(),
ok,
});
}
pub fn finish(&self) -> TraceReport {
evaluate_trace(&self.rules, &self.trace)
}
pub fn trace(&self) -> &[TraceStep] {
&self.trace
}
}
#[cfg(test)]
mod gate_tests {
use super::*;
fn rules() -> Vec<TraceRule> {
vec![
TraceRule::Precedes {
earlier: "test".into(),
later: "deploy".into(),
name: None,
},
TraceRule::Eventually {
trigger: "acquire_lease".into(),
required: "release_lease".into(),
name: None,
},
]
}
#[test]
fn an_empty_gate_allows_everything() {
let gate = TraceGate::default();
assert!(gate.is_empty());
assert!(gate.check("anything").is_empty());
assert!(gate.finish().compliant);
}
#[test]
fn the_gate_refuses_then_allows_as_the_run_progresses() {
let mut gate = TraceGate::new(rules());
assert_eq!(gate.check("deploy").len(), 1, "no test has run yet");
gate.record("test", true);
assert!(gate.check("deploy").is_empty(), "the precondition is met");
}
#[test]
fn a_failed_precondition_keeps_the_gate_shut() {
let mut gate = TraceGate::new(rules());
gate.record("test", false);
assert_eq!(gate.check("deploy").len(), 1);
}
#[test]
fn ungoverned_tools_are_allowed_but_still_recorded() {
let mut gate = TraceGate::new(rules());
assert!(gate.check("read_file").is_empty());
gate.record("read_file", true);
assert_eq!(gate.trace().len(), 1);
}
#[test]
fn an_undischarged_obligation_surfaces_only_at_finish() {
let mut gate = TraceGate::new(rules());
gate.record("acquire_lease", true);
assert!(
gate.check("anything").is_empty(),
"a prefix must not block on liveness"
);
let report = gate.finish();
assert!(!report.compliant, "the completed run is non-compliant");
let mut gate = TraceGate::new(rules());
gate.record("acquire_lease", true);
gate.record("release_lease", true);
assert!(gate.finish().compliant);
}
}