Skip to main content

agent_graph_mcp/
promotion.rs

1//! Canonical, operator-authorized template promotion.
2//! Outcome evidence is advisory; only an authenticated operator receipt can approve.
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::HashSet;
6use std::sync::{Mutex, OnceLock};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum TemplateCandidateState {
11    LegacyUnverified,
12    Candidate,
13    Quarantined,
14    Approved,
15    Revoked,
16}
17
18impl TemplateCandidateState {
19    pub fn as_str(self) -> &'static str {
20        match self {
21            Self::LegacyUnverified => "legacy_unverified",
22            Self::Candidate => "candidate",
23            Self::Quarantined => "quarantined",
24            Self::Approved => "approved",
25            Self::Revoked => "revoked",
26        }
27    }
28    pub fn can_transition(self, to: Self) -> bool {
29        match self {
30            Self::LegacyUnverified => to == Self::Quarantined,
31            Self::Candidate => matches!(to, Self::Candidate | Self::Quarantined | Self::Approved),
32            Self::Approved => matches!(to, Self::Approved | Self::Quarantined | Self::Revoked),
33            Self::Quarantined => to == Self::Candidate,
34            Self::Revoked => false,
35        }
36    }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct TemplateOutcome {
41    pub template_id: String,
42    pub run_id: String,
43    pub terminal_receipt_id: String,
44    pub receipt_digest: String,
45    pub disposition: String,
46    pub evidence_digest: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct OperatorReceipt {
51    pub operator_id: String,
52    pub nonce: String,
53    pub authenticated: bool,
54}
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum PromotionError {
57    Unauthorized,
58    AuthorizationReplayed,
59    NotFound,
60    InvalidTransition,
61    NotEligible,
62    ReceiptMismatch(String),
63    NonTerminal,
64}
65
66static NONCES: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
67fn nonces() -> &'static Mutex<HashSet<String>> {
68    NONCES.get_or_init(|| Mutex::new(HashSet::new()))
69}
70
71#[derive(Debug, Clone)]
72pub struct PromotionStore {
73    pub template_id: String,
74    pub spec_digest: String,
75    pub graph_id: String,
76    pub graph_version: String,
77    pub state: TemplateCandidateState,
78    pub outcomes: Vec<TemplateOutcome>,
79    pub decisions: Vec<String>,
80}
81impl PromotionStore {
82    pub fn new(template_id: &str, spec_digest: &str, graph_id: &str, graph_version: &str) -> Self {
83        Self {
84            template_id: template_id.into(),
85            spec_digest: spec_digest.into(),
86            graph_id: graph_id.into(),
87            graph_version: graph_version.into(),
88            state: TemplateCandidateState::Candidate,
89            outcomes: vec![],
90            decisions: vec![],
91        }
92    }
93    pub fn add_outcome(&mut self, o: TemplateOutcome) -> Result<(), PromotionError> {
94        if o.template_id != self.template_id {
95            return Err(PromotionError::ReceiptMismatch("template_id".into()));
96        }
97        if self.outcomes.iter().any(|x| x.run_id == o.run_id) {
98            return Ok(());
99        }
100        if o.disposition.eq_ignore_ascii_case("bad")
101            || o.disposition.eq_ignore_ascii_case("failed")
102            || o.disposition.eq_ignore_ascii_case("contradicted")
103        {
104            self.state = TemplateCandidateState::Quarantined;
105        }
106        self.outcomes.push(o);
107        Ok(())
108    }
109    pub fn eligible(&self) -> bool {
110        self.state == TemplateCandidateState::Candidate
111            && self
112                .outcomes
113                .iter()
114                .filter(|o| {
115                    o.disposition.eq_ignore_ascii_case("good")
116                        || o.disposition.eq_ignore_ascii_case("supported")
117                })
118                .count()
119                >= 3
120    }
121    pub fn promote_template(&mut self, receipt: OperatorReceipt) -> Result<(), PromotionError> {
122        if !receipt.authenticated || !receipt.operator_id.starts_with("operator:") {
123            return Err(PromotionError::Unauthorized);
124        }
125        let mut used = nonces().lock().unwrap();
126        if !used.insert(receipt.nonce.clone()) {
127            return Err(PromotionError::AuthorizationReplayed);
128        }
129        if !self.eligible() {
130            return Err(PromotionError::NotEligible);
131        }
132        if !self.state.can_transition(TemplateCandidateState::Approved) {
133            return Err(PromotionError::InvalidTransition);
134        }
135        self.state = TemplateCandidateState::Approved;
136        self.decisions.push(receipt.nonce);
137        Ok(())
138    }
139}
140
141pub fn verify_canonical_receipt(
142    run_id: &str,
143    receipt_digest: &str,
144    graph_id: &str,
145    graph_version: &str,
146    template_id: &str,
147    spec_digest: &str,
148    actual_run_id: &str,
149    actual_receipt_digest: &str,
150    actual_graph_id: &str,
151    actual_graph_version: &str,
152    actual_template_id: &str,
153    actual_spec_digest: &str,
154    terminal: bool,
155) -> Result<(), PromotionError> {
156    for (name, a, b) in [
157        ("run_id", run_id, actual_run_id),
158        ("receipt_digest", receipt_digest, actual_receipt_digest),
159        ("graph_id", graph_id, actual_graph_id),
160        ("graph_version", graph_version, actual_graph_version),
161        ("template_id", template_id, actual_template_id),
162        ("spec_digest", spec_digest, actual_spec_digest),
163    ] {
164        if a != b {
165            return Err(PromotionError::ReceiptMismatch(name.into()));
166        }
167    }
168    if !terminal {
169        return Err(PromotionError::NonTerminal);
170    }
171    Ok(())
172}
173pub fn evidence_digest(outcomes: &[TemplateOutcome]) -> String {
174    let mut h = Sha256::new();
175    for o in outcomes {
176        h.update(format!(
177            "{}:{}:{}:{}:{}:{}\n",
178            o.template_id,
179            o.run_id,
180            o.terminal_receipt_id,
181            o.receipt_digest,
182            o.disposition,
183            o.evidence_digest
184        ));
185    }
186    format!("{:x}", h.finalize())
187}