salvor_engine/approval.rs
1//! Enforcing a gate's `approval_schema` against the input a human resumes with.
2//!
3//! A `gate` node carries an `approval_schema`: the JSON Schema the human
4//! approval must satisfy. Until now that schema was recorded (it becomes the
5//! `Suspended` event's `input_schema`) and advertised (the approval inbox and
6//! the CLI both print it), but nothing ever checked an approval against it, so
7//! a gate declaring `{"required": ["approved"]}` accepted `null`, `42`,
8//! `"nope"`, and `{}` alike. This module is the check.
9//!
10//! # The accept edge
11//!
12//! The one place validation may happen is the **accept edge**: the moment a
13//! resume input is about to become a `Resumed` event, and before that event is
14//! appended. In the engine that edge is in `run_graph`'s `Node::Gate` arm,
15//! between `RunCtx::suspend` and `RunCtx::await_resume` (see the comment
16//! there). The server and the CLI each run the same check one layer earlier, so
17//! the operator gets a synchronous refusal instead of a driver task that dies
18//! quietly; the engine's check is the backstop that a direct library caller
19//! also gets.
20//!
21//! Validation must NEVER move to the other side of that edge. A recorded
22//! `Resumed` event is history: replay trusts it and re-feeds it to the gate
23//! verbatim. If replay re-validated it, tightening this validator (or bumping
24//! the `jsonschema` crate) could turn a log that replayed yesterday into a
25//! refusal today, which is exactly the property durable execution sells. So the
26//! rule is: check what has not been written yet, trust what has.
27//!
28//! # What "conforms" means
29//!
30//! The schema is handed to the [`jsonschema`] crate, which implements drafts 4,
31//! 6, 7, 2019-09 and 2020-12 and picks 2020-12 when the schema declares no
32//! `$schema`. That is a real validator, not the structural subset
33//! `salvor_runtime::validate_against_schema` applies to tool suspensions.
34//!
35//! On top of it sits ONE rule the JSON Schema specification does not give for
36//! free, and it is the rule that closes most of the hole. Under the spec,
37//! `required` and `properties` are *object* keywords: applied to `null`, `42`,
38//! or `"nope"`, they are vacuously satisfied, because those instances have no
39//! properties to require. A spec-perfect validator therefore approves all
40//! three against `{"required": ["approved"], "properties": {...}}`. That is
41//! correct JSON Schema and useless as an approval gate. So: a top-level
42//! approval schema that names object shape (`required`, `properties`, and their
43//! kin) and does not otherwise say what type it wants is read as asking for an
44//! object, and a non-object approval is a violation. See `implies_object`
45//! below. The rule is deliberately top-level only: it is about
46//! what the approval AS A WHOLE is, and a nested `anyOf` branch may legitimately
47//! not be an object. An author who really wants to accept a bare `42` says so
48//! with `"type"`, `enum`, `const`, or a combinator, all of which switch the rule
49//! off.
50//!
51//! One deliberate soft edge: a schema the validator cannot COMPILE (a gate
52//! whose `approval_schema` is a legal JSON object but not a legal JSON Schema)
53//! constrains nothing, and every input passes. The alternative is worse. The
54//! graph document validator already accepted that gate and runs are already
55//! parked at it; refusing to compile mid-flight would strand those runs with no
56//! way forward but abandonment. Fail-open here matches how the older structural
57//! validator treated keywords it did not implement: an input is never rejected
58//! for a reason the schema author cannot see.
59
60use salvor_core::{Event, EventEnvelope};
61use salvor_graph::{GateNode, Graph, Node};
62use serde_json::Value;
63use std::fmt;
64
65/// The most violations one refusal reports. An approval schema is a small,
66/// hand-written document, so this is never reached in practice; it exists so a
67/// pathological input cannot turn one 400 response into an unbounded body.
68const MAX_VIOLATIONS: usize = 20;
69
70/// One way an approval input failed its gate's `approval_schema`.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ApprovalViolation {
73 /// Where in the input the violation is, in the `$.field[0]` form the rest
74 /// of the codebase's schema messages use. The whole input is `$`.
75 pub path: String,
76 /// What was wrong there, in the validator's own words.
77 pub message: String,
78}
79
80impl fmt::Display for ApprovalViolation {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 write!(f, "{}: {}", self.path, self.message)
83 }
84}
85
86/// Every way `input` fails `schema`, in a stable order. An empty list means the
87/// input conforms.
88///
89/// The order is sorted by path then message rather than left as the validator's
90/// iteration order, so the list a refusal prints (and the list a test asserts
91/// on) does not move when the validator's internals do.
92///
93/// A schema that will not compile as JSON Schema yields no violations; see the
94/// module docs for why that soft edge is deliberate.
95#[must_use]
96pub fn approval_violations(input: &Value, schema: &Value) -> Vec<ApprovalViolation> {
97 let mut violations: Vec<ApprovalViolation> = Vec::new();
98 if implies_object(schema) && !input.is_object() {
99 violations.push(ApprovalViolation {
100 path: "$".to_owned(),
101 message: format!(
102 "expected an object, got {}: this gate's approval_schema names the properties an \
103 approval must carry, so only an object can answer it",
104 type_name(input)
105 ),
106 });
107 }
108 let Ok(validator) = jsonschema::validator_for(schema) else {
109 return violations;
110 };
111 violations.extend(
112 validator
113 .iter_errors(input)
114 .take(MAX_VIOLATIONS)
115 .map(|error| ApprovalViolation {
116 path: pointer_to_path(&error.instance_path().to_string()),
117 message: error.to_string(),
118 }),
119 );
120 violations.sort();
121 violations.dedup();
122 violations.truncate(MAX_VIOLATIONS);
123 violations
124}
125
126/// Whether a top-level approval schema is asking for an object without saying
127/// so: it names object shape, and it names no other way of deciding what it
128/// accepts.
129///
130/// Any of `type`, `enum`, `const`, `$ref`, or a combinator (`anyOf`, `oneOf`,
131/// `allOf`, `not`, `if`) means the author has expressed an intent about the
132/// instance's form, and the specification's own semantics are then exactly
133/// right; this rule stays out of the way.
134fn implies_object(schema: &Value) -> bool {
135 let Some(schema) = schema.as_object() else {
136 return false;
137 };
138 const SPEAKS_FOR_ITSELF: [&str; 9] = [
139 "type", "enum", "const", "$ref", "anyOf", "oneOf", "allOf", "not", "if",
140 ];
141 if SPEAKS_FOR_ITSELF
142 .iter()
143 .any(|key| schema.contains_key(*key))
144 {
145 return false;
146 }
147 const OBJECT_SHAPE: [&str; 8] = [
148 "required",
149 "properties",
150 "patternProperties",
151 "additionalProperties",
152 "propertyNames",
153 "minProperties",
154 "maxProperties",
155 "dependentRequired",
156 ];
157 OBJECT_SHAPE.iter().any(|key| schema.contains_key(*key))
158}
159
160/// The JSON type name of a value, for the one message this module writes
161/// itself. Matches `salvor_runtime::validate_against_schema`'s vocabulary.
162fn type_name(value: &Value) -> &'static str {
163 match value {
164 Value::Null => "null",
165 Value::Bool(_) => "boolean",
166 Value::Number(_) => "number",
167 Value::String(_) => "string",
168 Value::Array(_) => "array",
169 Value::Object(_) => "object",
170 }
171}
172
173impl PartialOrd for ApprovalViolation {
174 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
175 Some(self.cmp(other))
176 }
177}
178
179impl Ord for ApprovalViolation {
180 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
181 (&self.path, &self.message).cmp(&(&other.path, &other.message))
182 }
183}
184
185/// The gate a parked run is waiting at, or `None` when it is parked somewhere
186/// else (a tool suspension, a budget crossing) or at a node this document has
187/// no gate for.
188///
189/// A parked log ends at its `Suspended` event, and the node that suspended is
190/// the last `NodeEntered` before it. That is enough: the server and the CLI
191/// both hold the log and the document at resume time, so neither needs a new
192/// event field to find out which gate is being answered.
193#[must_use]
194pub fn parked_gate<'g>(log: &[EventEnvelope], graph: &'g Graph) -> Option<&'g GateNode> {
195 let node = log
196 .iter()
197 .rev()
198 .find_map(|envelope| match &envelope.event {
199 Event::NodeEntered { node } => Some(node.as_str()),
200 _ => None,
201 })?;
202 graph.nodes.iter().find_map(|candidate| match candidate {
203 Node::Gate(gate) if gate.id == node => Some(gate),
204 _ => None,
205 })
206}
207
208/// Renders a JSON Pointer (`/targets/0`) as the `$.targets[0]` form the rest of
209/// the codebase's schema messages use. The empty pointer, meaning the whole
210/// instance, is `$`.
211fn pointer_to_path(pointer: &str) -> String {
212 let mut path = String::from("$");
213 for segment in pointer.split('/').skip(1) {
214 if segment.parse::<usize>().is_ok() {
215 path.push('[');
216 path.push_str(segment);
217 path.push(']');
218 } else {
219 path.push('.');
220 // Undo the JSON Pointer escapes, so a property literally named
221 // `a/b` reads back as `a/b` rather than `a~1b`.
222 path.push_str(&segment.replace("~1", "/").replace("~0", "~"));
223 }
224 }
225 path
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use serde_json::json;
232
233 /// The schema the task's reproduction used: `required` and `properties`
234 /// with no top-level `type`. The four inputs that used to sail through it
235 /// all have to be violations now.
236 #[test]
237 fn the_four_reproduced_inputs_are_violations() {
238 let schema = json!({
239 "required": ["approved"],
240 "properties": {"approved": {"type": "boolean"}}
241 });
242 for bad in [json!(null), json!(42), json!("nope"), json!({})] {
243 assert!(
244 !approval_violations(&bad, &schema).is_empty(),
245 "{bad} must not approve"
246 );
247 }
248 assert_eq!(approval_violations(&json!({"approved": true}), &schema), []);
249 assert_eq!(
250 approval_violations(&json!({"approved": false}), &schema),
251 []
252 );
253 }
254
255 /// Every violation is reported, not just the first, and each names where it
256 /// is.
257 #[test]
258 fn violations_name_their_paths_and_are_all_reported() {
259 let schema = json!({
260 "type": "object",
261 "required": ["approved", "targets"],
262 "properties": {
263 "approved": {"type": "boolean"},
264 "targets": {"type": "array", "items": {"type": "string"}}
265 }
266 });
267 let violations = approval_violations(&json!({"approved": "yes", "targets": [1]}), &schema);
268 let paths: Vec<&str> = violations.iter().map(|v| v.path.as_str()).collect();
269 assert_eq!(paths, ["$.approved", "$.targets[0]"], "{violations:?}");
270 assert!(violations[0].message.contains("boolean"), "{violations:?}");
271 }
272
273 /// A schema that says what form it wants, by any of the means an author
274 /// has, switches the implied-object rule off and gets plain JSON Schema
275 /// semantics.
276 #[test]
277 fn a_schema_that_states_its_own_form_is_left_alone() {
278 let choice = json!({"enum": ["approve", "reject"]});
279 assert_eq!(approval_violations(&json!("approve"), &choice), []);
280 assert!(!approval_violations(&json!("maybe"), &choice).is_empty());
281
282 let either = json!({
283 "anyOf": [{"type": "boolean"}, {"type": "object", "required": ["approved"]}]
284 });
285 assert_eq!(approval_violations(&json!(true), &either), []);
286 assert_eq!(
287 approval_violations(&json!({"approved": false}), &either),
288 []
289 );
290 assert!(!approval_violations(&json!("nope"), &either).is_empty());
291
292 // An explicit `type` that admits a non-object is honored, even
293 // alongside `properties`.
294 let lenient = json!({"type": ["object", "null"], "properties": {"a": {}}});
295 assert_eq!(approval_violations(&json!(null), &lenient), []);
296 }
297
298 /// The soft edge: an approval schema that is not a compilable JSON Schema
299 /// constrains nothing rather than stranding the run.
300 #[test]
301 fn an_uncompilable_schema_constrains_nothing() {
302 let schema = json!({"type": "not-a-json-type"});
303 assert_eq!(approval_violations(&json!(null), &schema), []);
304 }
305
306 /// The pointer rendering, including an escaped property name.
307 #[test]
308 fn pointers_render_in_the_codebase_path_style() {
309 assert_eq!(pointer_to_path(""), "$");
310 assert_eq!(pointer_to_path("/approved"), "$.approved");
311 assert_eq!(pointer_to_path("/targets/0/url"), "$.targets[0].url");
312 assert_eq!(pointer_to_path("/a~1b"), "$.a/b");
313 }
314}