Skip to main content

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}