use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkflowEdge {
pub from: String,
pub to: String,
#[serde(default)]
pub condition: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkflowGraph {
pub entry: String,
#[serde(default)]
pub terminals: Vec<String>,
#[serde(default)]
pub stages: Vec<String>,
#[serde(default)]
pub edges: Vec<WorkflowEdge>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowDefectKind {
MissingEntry,
DanglingEdge,
UnreachableStage,
DeadEnd,
NoExitReachable,
UnreachableTerminal,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkflowDefect {
pub kind: WorkflowDefectKind,
pub subject: String,
pub witness: Vec<String>,
pub explanation: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkflowVerifyReport {
pub sound: bool,
pub defects: Vec<WorkflowDefect>,
}
impl WorkflowVerifyReport {
pub const fn evidence_tier(&self) -> crate::EvidenceTier {
crate::EvidenceTier::DecisionProcedure
}
}
fn bfs(start: &str, adj: &HashMap<&str, Vec<&str>>) -> (HashSet<String>, HashMap<String, String>) {
let mut seen = HashSet::new();
let mut parent: HashMap<String, String> = HashMap::new();
let mut q = VecDeque::new();
seen.insert(start.to_string());
q.push_back(start.to_string());
while let Some(n) = q.pop_front() {
if let Some(succs) = adj.get(n.as_str()) {
for &s in succs {
if seen.insert(s.to_string()) {
parent.insert(s.to_string(), n.clone());
q.push_back(s.to_string());
}
}
}
}
(seen, parent)
}
fn witness_path(entry: &str, target: &str, parent: &HashMap<String, String>) -> Vec<String> {
let mut path = vec![target.to_string()];
let mut cur = target.to_string();
while cur != entry {
match parent.get(&cur) {
Some(p) => {
path.push(p.clone());
cur = p.clone();
}
None => break,
}
}
path.reverse();
path
}
pub fn verify_workflow_graph(g: &WorkflowGraph) -> WorkflowVerifyReport {
let mut defects = Vec::new();
let stage_set: HashSet<&str> = g.stages.iter().map(|s| s.as_str()).collect();
if g.entry.is_empty() || !stage_set.contains(g.entry.as_str()) {
defects.push(WorkflowDefect {
kind: WorkflowDefectKind::MissingEntry,
subject: g.entry.clone(),
witness: vec![],
explanation: if g.entry.is_empty() {
"workflow has no entry stage".to_string()
} else {
format!("entry stage '{}' is not a declared stage", g.entry)
},
});
return WorkflowVerifyReport {
sound: false,
defects,
};
}
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in &g.edges {
let from_ok = stage_set.contains(e.from.as_str());
let to_ok = stage_set.contains(e.to.as_str());
if !from_ok || !to_ok {
defects.push(WorkflowDefect {
kind: WorkflowDefectKind::DanglingEdge,
subject: format!("{}->{}", e.from, e.to),
witness: vec![],
explanation: format!(
"edge '{}'->'{}' references {} stage",
e.from,
e.to,
if !from_ok && !to_ok {
"two undeclared"
} else {
"an undeclared"
}
),
});
continue;
}
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
}
let (reachable, parent) = bfs(&g.entry, &adj);
let terminal_set: HashSet<&str> = g.terminals.iter().map(|s| s.as_str()).collect();
for s in &g.stages {
if !reachable.contains(s) {
defects.push(WorkflowDefect {
kind: WorkflowDefectKind::UnreachableStage,
subject: s.clone(),
witness: vec![],
explanation: format!("stage '{s}' is not reachable from entry '{}'", g.entry),
});
}
}
for t in &g.terminals {
if stage_set.contains(t.as_str()) && !reachable.contains(t) {
defects.push(WorkflowDefect {
kind: WorkflowDefectKind::UnreachableTerminal,
subject: t.clone(),
witness: vec![],
explanation: format!("terminal '{t}' is not reachable from entry '{}'", g.entry),
});
}
}
let mut radj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in &g.edges {
if stage_set.contains(e.from.as_str()) && stage_set.contains(e.to.as_str()) {
radj.entry(e.to.as_str()).or_default().push(e.from.as_str());
}
}
let mut can_exit: HashSet<String> = HashSet::new();
let mut q = VecDeque::new();
for t in &g.terminals {
if stage_set.contains(t.as_str()) && can_exit.insert(t.clone()) {
q.push_back(t.clone());
}
}
while let Some(n) = q.pop_front() {
if let Some(preds) = radj.get(n.as_str()) {
for &p in preds {
if can_exit.insert(p.to_string()) {
q.push_back(p.to_string());
}
}
}
}
for s in &g.stages {
if !reachable.contains(s) || terminal_set.contains(s.as_str()) {
continue;
}
let has_out = adj.get(s.as_str()).map(|v| !v.is_empty()).unwrap_or(false);
if !has_out {
defects.push(WorkflowDefect {
kind: WorkflowDefectKind::DeadEnd,
subject: s.clone(),
witness: witness_path(&g.entry, s, &parent),
explanation: format!(
"stage '{s}' is non-terminal but has no outgoing edge — execution stalls"
),
});
} else if !can_exit.contains(s) {
defects.push(WorkflowDefect {
kind: WorkflowDefectKind::NoExitReachable,
subject: s.clone(),
witness: witness_path(&g.entry, s, &parent),
explanation: format!(
"no terminal is reachable from stage '{s}' — it is a trap or an exitless loop"
),
});
}
}
if g.terminals.is_empty() {
defects.push(WorkflowDefect {
kind: WorkflowDefectKind::NoExitReachable,
subject: g.entry.clone(),
witness: vec![g.entry.clone()],
explanation: "workflow declares no terminal stages — it can never exit".to_string(),
});
}
WorkflowVerifyReport {
sound: defects.is_empty(),
defects,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TemporalPolicy {
Precedes {
earlier: String,
later: String,
#[serde(default)]
name: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PolicyViolation {
pub policy: String,
pub stage: String,
pub witness: Vec<String>,
pub explanation: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PolicyReport {
pub compliant: bool,
pub violations: Vec<PolicyViolation>,
}
impl PolicyReport {
pub const fn evidence_tier(&self) -> crate::EvidenceTier {
crate::EvidenceTier::DecisionProcedure
}
}
pub fn check_temporal_policies(g: &WorkflowGraph, policies: &[TemporalPolicy]) -> PolicyReport {
let stage_set: HashSet<&str> = g.stages.iter().map(|s| s.as_str()).collect();
let mut violations = Vec::new();
for p in policies {
match p {
TemporalPolicy::Precedes {
earlier,
later,
name,
} => {
let label = name
.clone()
.unwrap_or_else(|| format!("{earlier} precedes {later}"));
if &g.entry == earlier {
continue;
}
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in &g.edges {
if e.from == *earlier || e.to == *earlier {
continue; }
if stage_set.contains(e.from.as_str()) && stage_set.contains(e.to.as_str()) {
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
}
}
let (reached, parent) = bfs(&g.entry, &adj);
if reached.contains(later) {
violations.push(PolicyViolation {
policy: label,
stage: later.clone(),
witness: witness_path(&g.entry, later, &parent),
explanation: format!(
"stage '{later}' is reachable without first visiting required stage \
'{earlier}' — the precedence policy can be violated"
),
});
}
}
}
}
PolicyReport {
compliant: violations.is_empty(),
violations,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn edge(from: &str, to: &str) -> WorkflowEdge {
WorkflowEdge {
from: from.into(),
to: to.into(),
condition: None,
}
}
fn graph(
entry: &str,
terminals: &[&str],
stages: &[&str],
edges: &[(&str, &str)],
) -> WorkflowGraph {
WorkflowGraph {
entry: entry.into(),
terminals: terminals.iter().map(|s| s.to_string()).collect(),
stages: stages.iter().map(|s| s.to_string()).collect(),
edges: edges.iter().map(|(f, t)| edge(f, t)).collect(),
}
}
#[test]
fn linear_workflow_is_sound() {
let g = graph(
"start",
&["done"],
&["start", "work", "done"],
&[("start", "work"), ("work", "done")],
);
let r = verify_workflow_graph(&g);
assert!(r.sound, "{:?}", r.defects);
}
#[test]
fn missing_entry_is_flagged_and_short_circuits() {
let g = graph("ghost", &["done"], &["start", "done"], &[("start", "done")]);
let r = verify_workflow_graph(&g);
assert!(!r.sound);
assert_eq!(r.defects.len(), 1);
assert_eq!(r.defects[0].kind, WorkflowDefectKind::MissingEntry);
}
#[test]
fn dangling_edge_is_flagged() {
let g = graph(
"start",
&["done"],
&["start", "done"],
&[("start", "nowhere"), ("start", "done")],
);
let r = verify_workflow_graph(&g);
assert!(r
.defects
.iter()
.any(|d| d.kind == WorkflowDefectKind::DanglingEdge && d.subject == "start->nowhere"));
}
#[test]
fn unreachable_stage_is_flagged() {
let g = graph(
"start",
&["done"],
&["start", "orphan", "done"],
&[("start", "done"), ("orphan", "done")],
);
let r = verify_workflow_graph(&g);
assert!(r
.defects
.iter()
.any(|d| d.kind == WorkflowDefectKind::UnreachableStage && d.subject == "orphan"));
}
#[test]
fn dead_end_has_witness_path() {
let g = graph(
"start",
&["done"],
&["start", "trap", "done"],
&[("start", "trap"), ("start", "done")],
);
let r = verify_workflow_graph(&g);
let d = r
.defects
.iter()
.find(|d| d.kind == WorkflowDefectKind::DeadEnd)
.expect("dead end");
assert_eq!(d.subject, "trap");
assert_eq!(d.witness, vec!["start".to_string(), "trap".to_string()]);
}
#[test]
fn exitless_loop_is_no_exit_reachable() {
let g = graph(
"start",
&["done"],
&["start", "a", "b", "done"],
&[("start", "a"), ("a", "b"), ("b", "a")],
);
let r = verify_workflow_graph(&g);
assert!(r
.defects
.iter()
.any(|d| d.kind == WorkflowDefectKind::NoExitReachable));
assert!(r
.defects
.iter()
.any(|d| d.kind == WorkflowDefectKind::UnreachableTerminal && d.subject == "done"));
}
#[test]
fn no_terminals_can_never_exit() {
let g = graph(
"start",
&[],
&["start", "work"],
&[("start", "work"), ("work", "start")],
);
let r = verify_workflow_graph(&g);
assert!(r
.defects
.iter()
.any(|d| d.kind == WorkflowDefectKind::NoExitReachable));
}
#[test]
fn conditional_branches_both_reaching_exit_are_sound() {
let g = graph(
"start",
&["done"],
&["start", "yes", "no", "done"],
&[
("start", "yes"),
("start", "no"),
("yes", "done"),
("no", "done"),
],
);
let r = verify_workflow_graph(&g);
assert!(r.sound, "{:?}", r.defects);
}
fn precedes(earlier: &str, later: &str) -> TemporalPolicy {
TemporalPolicy::Precedes {
earlier: earlier.into(),
later: later.into(),
name: None,
}
}
#[test]
fn gate_on_every_path_is_compliant() {
let g = graph(
"start",
&["done"],
&["start", "gate", "act", "done"],
&[("start", "gate"), ("gate", "act"), ("act", "done")],
);
let r = check_temporal_policies(&g, &[precedes("gate", "act")]);
assert!(r.compliant, "{:?}", r.violations);
}
#[test]
fn bypass_path_violates_with_witness() {
let g = graph(
"start",
&["done"],
&["start", "gate", "act", "done"],
&[
("start", "gate"),
("gate", "act"),
("start", "act"), ("act", "done"),
],
);
let r = check_temporal_policies(&g, &[precedes("gate", "act")]);
assert!(!r.compliant);
let v = &r.violations[0];
assert_eq!(v.stage, "act");
assert!(!v.witness.contains(&"gate".to_string()));
assert_eq!(v.witness, vec!["start".to_string(), "act".to_string()]);
}
#[test]
fn entry_as_gate_is_trivially_satisfied() {
let g = graph(
"gate",
&["done"],
&["gate", "act", "done"],
&[("gate", "act"), ("act", "done")],
);
let r = check_temporal_policies(&g, &[precedes("gate", "act")]);
assert!(r.compliant);
}
}