Skip to main content

runx_runtime/
approval.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use runx_contracts::{
4    ApprovalGate, ExecutionEvent, JsonObject, JsonValue, ResolutionRequest, ResolutionResponse,
5    ResolutionResponseActor, sha256_prefixed,
6};
7use thiserror::Error;
8
9use crate::{Host, RuntimeError};
10
11#[derive(Debug, Error)]
12pub enum ApprovalError {
13    #[error(transparent)]
14    Runtime(#[from] RuntimeError),
15    #[error(
16        "approval response payload from {actor:?} must be a boolean or {{approved: bool, reason?: string}}, got {payload_type}"
17    )]
18    NonBooleanPayload {
19        actor: ResolutionResponseActor,
20        payload_type: &'static str,
21    },
22    #[error("approval gate serialization failed while {context}: {source}")]
23    Json {
24        context: String,
25        #[source]
26        source: serde_json::Error,
27    },
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum ApprovalResolution {
32    Approved {
33        actor: ResolutionResponseActor,
34        idempotency_key: String,
35        reason: Option<String>,
36    },
37    Denied {
38        actor: ResolutionResponseActor,
39        idempotency_key: String,
40        reason: Option<String>,
41    },
42    Pending {
43        idempotency_key: String,
44    },
45}
46
47impl ApprovalResolution {
48    #[must_use]
49    pub fn approved(&self) -> Option<bool> {
50        match self {
51            Self::Approved { .. } => Some(true),
52            Self::Denied { .. } => Some(false),
53            Self::Pending { .. } => None,
54        }
55    }
56
57    #[must_use]
58    pub fn actor(&self) -> Option<&ResolutionResponseActor> {
59        match self {
60            Self::Approved { actor, .. } | Self::Denied { actor, .. } => Some(actor),
61            Self::Pending { .. } => None,
62        }
63    }
64
65    #[must_use]
66    pub fn reason(&self) -> Option<&str> {
67        match self {
68            Self::Approved { reason, .. } | Self::Denied { reason, .. } => reason.as_deref(),
69            Self::Pending { .. } => None,
70        }
71    }
72
73    #[must_use]
74    pub fn idempotency_key(&self) -> &str {
75        match self {
76            Self::Approved {
77                idempotency_key, ..
78            }
79            | Self::Denied {
80                idempotency_key, ..
81            }
82            | Self::Pending { idempotency_key } => idempotency_key,
83        }
84    }
85}
86
87#[derive(Debug, Default)]
88pub struct LocalApprovalGateResolver {
89    requested: BTreeSet<String>,
90    resolved: BTreeMap<String, CachedApproval>,
91}
92
93impl LocalApprovalGateResolver {
94    #[must_use]
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    pub fn request_approval(
100        &mut self,
101        host: &mut dyn Host,
102        id: impl Into<String>,
103        gate: ApprovalGate,
104    ) -> Result<ApprovalResolution, ApprovalError> {
105        let id = id.into();
106        let idempotency_key = approval_idempotency_key(&gate)?;
107        if let Some(cached) = self.resolved.get(&idempotency_key) {
108            return Ok(cached.resolution(idempotency_key));
109        }
110
111        self.report_requested(host, &id, &gate, &idempotency_key)?;
112        let response = host.resolve(ResolutionRequest::Approval {
113            id: id.clone().into(),
114            gate: gate.clone(),
115        })?;
116        let Some(response) = response else {
117            return Ok(ApprovalResolution::Pending { idempotency_key });
118        };
119
120        self.resolve_response(host, &id, &gate, idempotency_key, response)
121    }
122
123    fn report_requested(
124        &mut self,
125        host: &mut dyn Host,
126        id: &str,
127        gate: &ApprovalGate,
128        idempotency_key: &str,
129    ) -> Result<(), ApprovalError> {
130        if self.requested.insert(idempotency_key.to_owned()) {
131            host.report(requested_event(id, gate, idempotency_key))?;
132        }
133        Ok(())
134    }
135
136    fn resolve_response(
137        &mut self,
138        host: &mut dyn Host,
139        id: &str,
140        gate: &ApprovalGate,
141        idempotency_key: String,
142        response: ResolutionResponse,
143    ) -> Result<ApprovalResolution, ApprovalError> {
144        let cached = CachedApproval::from_response(response)?;
145        host.report(resolved_event(id, gate, &idempotency_key, &cached))?;
146        let resolution = cached.resolution(idempotency_key.clone());
147        self.resolved.insert(idempotency_key, cached);
148        Ok(resolution)
149    }
150}
151
152pub fn request_approval(
153    host: &mut dyn Host,
154    id: impl Into<String>,
155    gate: ApprovalGate,
156) -> Result<ApprovalResolution, ApprovalError> {
157    LocalApprovalGateResolver::new().request_approval(host, id, gate)
158}
159
160pub fn approval_idempotency_key(gate: &ApprovalGate) -> Result<String, ApprovalError> {
161    let canonical = serde_json::to_string(gate).map_err(|source| ApprovalError::Json {
162        context: "serializing approval gate".to_owned(),
163        source,
164    })?;
165    Ok(sha256_prefixed(canonical.as_bytes()))
166}
167
168#[derive(Clone, Debug)]
169struct CachedApproval {
170    actor: ResolutionResponseActor,
171    approved: bool,
172    reason: Option<String>,
173}
174
175impl CachedApproval {
176    // The single choke point for an approval decision. The boolean stays the
177    // decision: a bare `true`/`false` (the human lane) is byte-identical to
178    // before, and an agent decision may also arrive as
179    // `{ "approved": bool, "reason"?: string }` so its justification rides onto
180    // the receipt. Every other shape (including an object whose `approved` is
181    // not strictly a boolean) is rejected fail-closed via NonBooleanPayload.
182    fn from_response(response: ResolutionResponse) -> Result<Self, ApprovalError> {
183        let payload_type = payload_type(&response.payload);
184        let actor = response.actor;
185        let reject = || ApprovalError::NonBooleanPayload {
186            actor: actor.clone(),
187            payload_type,
188        };
189        match response.payload {
190            JsonValue::Bool(approved) => Ok(Self {
191                actor,
192                approved,
193                reason: None,
194            }),
195            JsonValue::Object(object) => {
196                let Some(JsonValue::Bool(approved)) = object.get("approved") else {
197                    return Err(reject());
198                };
199                let reason = match object.get("reason") {
200                    None | Some(JsonValue::Null) => None,
201                    Some(JsonValue::String(reason)) if !reason.trim().is_empty() => {
202                        Some(reason.clone())
203                    }
204                    Some(_) => return Err(reject()),
205                };
206                Ok(Self {
207                    actor,
208                    approved: *approved,
209                    reason,
210                })
211            }
212            _ => Err(reject()),
213        }
214    }
215
216    fn resolution(&self, idempotency_key: String) -> ApprovalResolution {
217        if self.approved {
218            ApprovalResolution::Approved {
219                actor: self.actor.clone(),
220                idempotency_key,
221                reason: self.reason.clone(),
222            }
223        } else {
224            ApprovalResolution::Denied {
225                actor: self.actor.clone(),
226                idempotency_key,
227                reason: self.reason.clone(),
228            }
229        }
230    }
231}
232
233fn requested_event(id: &str, gate: &ApprovalGate, idempotency_key: &str) -> ExecutionEvent {
234    ExecutionEvent::ResolutionRequested {
235        message: format!("approval {} requested", gate.id),
236        data: Some(JsonValue::Object(event_data(id, gate, idempotency_key))),
237    }
238}
239
240fn resolved_event(
241    id: &str,
242    gate: &ApprovalGate,
243    idempotency_key: &str,
244    approval: &CachedApproval,
245) -> ExecutionEvent {
246    let mut data = event_data(id, gate, idempotency_key);
247    data.insert(
248        "actor".to_owned(),
249        JsonValue::String(actor_name(&approval.actor)),
250    );
251    data.insert("approved".to_owned(), JsonValue::Bool(approval.approved));
252    if let Some(reason) = &approval.reason {
253        data.insert("reason".to_owned(), JsonValue::String(reason.clone()));
254    }
255    let decision = if approval.approved {
256        "approved"
257    } else {
258        "denied"
259    };
260    ExecutionEvent::ResolutionResolved {
261        message: format!("approval {} {decision}", gate.id),
262        data: Some(JsonValue::Object(data)),
263    }
264}
265
266fn event_data(id: &str, gate: &ApprovalGate, idempotency_key: &str) -> JsonObject {
267    let mut data = JsonObject::new();
268    data.insert("request_id".to_owned(), JsonValue::String(id.to_owned()));
269    data.insert(
270        "gate_id".to_owned(),
271        JsonValue::String(gate.id.as_str().to_owned()),
272    );
273    if let Some(gate_type) = &gate.gate_type {
274        data.insert("gate_type".to_owned(), JsonValue::String(gate_type.clone()));
275    }
276    data.insert(
277        "idempotency_key".to_owned(),
278        JsonValue::String(idempotency_key.to_owned()),
279    );
280    data
281}
282
283fn actor_name(actor: &ResolutionResponseActor) -> String {
284    match actor {
285        ResolutionResponseActor::Human => "human".to_owned(),
286        ResolutionResponseActor::Agent => "agent".to_owned(),
287    }
288}
289
290fn payload_type(payload: &JsonValue) -> &'static str {
291    match payload {
292        JsonValue::Null => "null",
293        JsonValue::Bool(_) => "boolean",
294        JsonValue::Number(_) => "number",
295        JsonValue::String(_) => "string",
296        JsonValue::Array(_) => "array",
297        JsonValue::Object(_) => "object",
298    }
299}