Skip to main content

boundgraph_check/
schema.rs

1//! The cheapest deterministic check there is: does this output match its
2//! declared shape?
3//!
4//! A step that promises `{ "severity": "high" | "low", "files": [string] }` and
5//! delivers prose has failed, and it has failed in a way that no downstream
6//! step can recover from — it will either crash on deserialisation or, worse,
7//! silently read a field that is not there. Catching it here costs microseconds
8//! and needs no tools, no sandbox, and no model.
9//!
10//! # A subset, and unknown keywords are refused
11//!
12//! This validates a documented subset of JSON Schema, listed on
13//! [`SUPPORTED_KEYWORDS`]. Anything outside it — `$ref`, `pattern`, `format`,
14//! `if`/`then`/`else` — makes [`Schema::compile`] **fail**.
15//!
16//! Refusing is the whole design, and the alternative is worse than it sounds.
17//! A validator that ignored the keywords it did not implement would hand a
18//! schema author a `Pass` on a payload their schema said was invalid, and
19//! nothing anywhere would report that a constraint had been skipped. That is a
20//! silent downgrade of a security property, which is the failure mode Goal G5
21//! exists to prevent. A build error naming the keyword is a cheap failure; a
22//! check that quietly stopped checking is not.
23//!
24//! # Why it cannot hang
25//!
26//! Two independent bounds.
27//!
28//! **The schema is a finite tree.** `$ref` is not supported, so a schema cannot
29//! refer to itself, and schema depth is capped at compile time
30//! ([`MAX_DEPTH`]). That is a stronger property than it first looks: validation
31//! descends only where the *schema* descends, so recursion is bounded by the
32//! schema and not by the instance. A payload nested a thousand levels deep
33//! against a two-level schema is walked two levels deep.
34//!
35//! **Validation has a step budget.** A finite schema still multiplies work:
36//! `oneOf` re-validates the same subtree once per alternative, and against a
37//! large array that compounds. So every comparison decrements a counter, and
38//! running out is [`Verdict::Inconclusive`](boundgraph_core::Verdict::Inconclusive) —
39//! never `Pass`, and never `Fail` either, because "we ran out of budget" is not
40//! the same claim as "your payload is wrong".
41//!
42//! What this module does *not* defend against is a payload so deeply nested
43//! that operating on it at all overflows the stack — `enum` and `uniqueItems`
44//! compare values structurally, and comparison recurses over the instance. That
45//! hazard is real but it is not created here and cannot be fixed here: by the
46//! time a `Condensate` exists, something has already built the value, and
47//! whatever built it would have overflowed first. Capping payload depth belongs
48//! where payloads are produced.
49//!
50//! # What a `Pass` actually guarantees
51//!
52//! That the payload was representable as JSON *and* satisfied the schema. Both
53//! halves matter: the checker converts the condensate's CBOR payload into JSON
54//! before validating, so a payload that could not survive that trip fails here
55//! rather than three steps later. A downstream step deserialising into a type
56//! that matches the schema will therefore succeed.
57
58use std::collections::{BTreeMap, BTreeSet};
59
60use async_trait::async_trait;
61use boundgraph_core::{CheckerId, Condensate, CondensatePayload, Digest, WorkOrder};
62use serde_json::Value;
63
64use crate::checker::{Checker, Judgement};
65
66/// The keywords this validator implements.
67///
68/// A schema using anything else is refused rather than partially checked. See
69/// the module documentation for why.
70pub const SUPPORTED_KEYWORDS: &[&str] = &[
71    "type",
72    "enum",
73    "const",
74    "properties",
75    "required",
76    "additionalProperties",
77    "minProperties",
78    "maxProperties",
79    "items",
80    "minItems",
81    "maxItems",
82    "uniqueItems",
83    "minimum",
84    "maximum",
85    "exclusiveMinimum",
86    "exclusiveMaximum",
87    "multipleOf",
88    "minLength",
89    "maxLength",
90    "allOf",
91    "anyOf",
92    "oneOf",
93    "not",
94];
95
96/// Keywords that carry no constraint and are skipped rather than refused.
97///
98/// Documentation, not validation. Refusing a schema for carrying a `title`
99/// would be pedantry; refusing one for carrying a `pattern` is the point.
100const ANNOTATIONS: &[&str] = &[
101    "$schema",
102    "$id",
103    "$comment",
104    "title",
105    "description",
106    "default",
107    "examples",
108    "deprecated",
109    "readOnly",
110    "writeOnly",
111];
112
113/// How deeply a schema may nest.
114///
115/// Generous for anything hand-written. A schema deeper than this is either
116/// generated or hostile, and both are worth stopping at compile time.
117pub const MAX_DEPTH: usize = 64;
118
119/// How many comparisons a validation may make before giving up.
120///
121/// Reached only by a schema that multiplies work — nested `oneOf` over a large
122/// array — against a large instance. Exhaustion is `Inconclusive`.
123pub const DEFAULT_BUDGET: usize = 100_000;
124
125/// A schema could not be compiled.
126///
127/// Every variant names the offending location as a JSON Pointer into the schema
128/// itself, so the author is told where to look rather than that "the schema is
129/// invalid".
130#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
131#[non_exhaustive]
132pub enum SchemaError {
133    /// The schema text was not JSON.
134    #[error("the schema is not valid JSON: {0}")]
135    NotJson(String),
136
137    /// A keyword outside the supported subset.
138    #[error("{path}: `{keyword}` is not supported by this validator, so the schema is refused")]
139    UnsupportedKeyword {
140        /// Where in the schema.
141        path: String,
142        /// Which keyword.
143        keyword: String,
144    },
145
146    /// A keyword was present but its value was the wrong shape.
147    #[error("{path}: `{keyword}` must be {expected}")]
148    MalformedKeyword {
149        /// Where in the schema.
150        path: String,
151        /// Which keyword.
152        keyword: String,
153        /// What it should have been.
154        expected: String,
155    },
156
157    /// A schema must be an object or a boolean.
158    #[error("{path}: a schema must be an object or a boolean, found {found}")]
159    NotASchema {
160        /// Where in the schema.
161        path: String,
162        /// What was there instead.
163        found: String,
164    },
165
166    /// The schema nests deeper than [`MAX_DEPTH`].
167    #[error("{path}: the schema nests deeper than {MAX_DEPTH} levels")]
168    TooDeep {
169        /// Where the limit was hit.
170        path: String,
171    },
172
173    /// The schema could not be content-addressed.
174    #[error("the schema could not be canonically encoded: {0}")]
175    Encode(String),
176}
177
178/// One way in which a value did not match its schema.
179///
180/// Names the location and the constraint separately, because a repair loop acts
181/// on both: the path says where to edit, the constraint says what rule was
182/// broken.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct Violation {
185    /// A JSON Pointer into the instance, e.g. `/items/2/name`. The empty string
186    /// is the root.
187    pub path: String,
188    /// The schema keyword that was not satisfied, e.g. `type`, `required`.
189    pub constraint: &'static str,
190    /// What was expected and what was found.
191    pub detail: String,
192}
193
194impl Violation {
195    /// One line, as it appears in a failure reason.
196    pub fn render(&self) -> String {
197        let at = if self.path.is_empty() {
198            "(root)".to_string()
199        } else {
200            self.path.clone()
201        };
202        format!("{at}: {} ({})", self.detail, self.constraint)
203    }
204}
205
206/// What validating a value produced.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum Validation {
209    /// The value satisfies the schema.
210    Valid,
211    /// It does not, and here is every reason why.
212    Invalid(Vec<Violation>),
213    /// The step budget ran out before an answer was reached.
214    ///
215    /// Deliberately not `Invalid`: a validator that ran out of budget has not
216    /// shown the value to be wrong, and reporting it as wrong would blame the
217    /// payload for the validator's limit.
218    Exhausted {
219        /// The budget that was exhausted.
220        budget: usize,
221    },
222}
223
224impl Validation {
225    /// Whether the value satisfied the schema.
226    ///
227    /// [`Exhausted`](Self::Exhausted) is not valid. It is not invalid either;
228    /// this is the question "may it pass", and the answer is no.
229    pub fn is_valid(&self) -> bool {
230        matches!(self, Self::Valid)
231    }
232
233    /// Whether validation reached an answer at all.
234    ///
235    /// `false` for exhaustion, which is what the checker turns into
236    /// [`Verdict::Inconclusive`](boundgraph_core::Verdict::Inconclusive).
237    pub fn is_decided(&self) -> bool {
238        matches!(self, Self::Valid | Self::Invalid(_))
239    }
240}
241
242/// A compiled schema: a finite tree of constraints, plus its content address.
243#[derive(Debug, Clone, PartialEq)]
244pub struct Schema {
245    root: Node,
246    source: Value,
247    digest: Digest,
248}
249
250/// One node of a compiled schema.
251#[derive(Debug, Clone, PartialEq, Default)]
252struct Node {
253    /// A boolean schema: `true` accepts everything, `false` accepts nothing.
254    boolean: Option<bool>,
255    types: Option<BTreeSet<Type>>,
256    enumeration: Option<Vec<Value>>,
257    constant: Option<Value>,
258    properties: BTreeMap<String, Node>,
259    required: Vec<String>,
260    additional: Option<Additional>,
261    min_properties: Option<usize>,
262    max_properties: Option<usize>,
263    items: Option<Box<Node>>,
264    min_items: Option<usize>,
265    max_items: Option<usize>,
266    unique_items: bool,
267    minimum: Option<f64>,
268    maximum: Option<f64>,
269    exclusive_minimum: Option<f64>,
270    exclusive_maximum: Option<f64>,
271    multiple_of: Option<f64>,
272    min_length: Option<usize>,
273    max_length: Option<usize>,
274    all_of: Vec<Node>,
275    any_of: Vec<Node>,
276    one_of: Vec<Node>,
277    not: Option<Box<Node>>,
278}
279
280/// What `additionalProperties` said.
281#[derive(Debug, Clone, PartialEq)]
282enum Additional {
283    /// `false`: unlisted properties are refused.
284    Forbidden,
285    /// A schema every unlisted property must satisfy. `true` compiles to an
286    /// empty schema, which accepts everything.
287    Schema(Box<Node>),
288}
289
290/// The JSON type names a schema may name.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
292enum Type {
293    Null,
294    Boolean,
295    Object,
296    Array,
297    Number,
298    Integer,
299    String,
300}
301
302impl Type {
303    fn parse(name: &str) -> Option<Self> {
304        Some(match name {
305            "null" => Self::Null,
306            "boolean" => Self::Boolean,
307            "object" => Self::Object,
308            "array" => Self::Array,
309            "number" => Self::Number,
310            "integer" => Self::Integer,
311            "string" => Self::String,
312            _ => return None,
313        })
314    }
315
316    fn name(self) -> &'static str {
317        match self {
318            Self::Null => "null",
319            Self::Boolean => "boolean",
320            Self::Object => "object",
321            Self::Array => "array",
322            Self::Number => "number",
323            Self::Integer => "integer",
324            Self::String => "string",
325        }
326    }
327
328    /// Whether `value` is of this type.
329    ///
330    /// `integer` accepts a number with no fractional part, as JSON Schema
331    /// requires — `2.0` is an integer, and refusing it would reject every
332    /// language that has only one numeric type.
333    fn matches(self, value: &Value) -> bool {
334        match self {
335            Self::Null => value.is_null(),
336            Self::Boolean => value.is_boolean(),
337            Self::Object => value.is_object(),
338            Self::Array => value.is_array(),
339            Self::Number => value.is_number(),
340            Self::Integer => value.as_f64().is_some_and(|n| n.fract() == 0.0),
341            Self::String => value.is_string(),
342        }
343    }
344}
345
346/// The name of a value's own type, for a diagnostic.
347fn type_of(value: &Value) -> &'static str {
348    match value {
349        Value::Null => "null",
350        Value::Bool(_) => "boolean",
351        Value::Number(_) => "number",
352        Value::String(_) => "string",
353        Value::Array(_) => "array",
354        Value::Object(_) => "object",
355    }
356}
357
358impl Schema {
359    /// Compile a schema from JSON text.
360    ///
361    /// # Errors
362    ///
363    /// [`SchemaError`] if the text is not JSON, or the schema uses a keyword
364    /// outside [`SUPPORTED_KEYWORDS`].
365    pub fn parse(text: &str) -> Result<Self, SchemaError> {
366        let value: Value =
367            serde_json::from_str(text).map_err(|e| SchemaError::NotJson(e.to_string()))?;
368        Self::compile(&value)
369    }
370
371    /// Compile an already-parsed schema.
372    ///
373    /// # Errors
374    ///
375    /// [`SchemaError`] if the schema uses a keyword outside
376    /// [`SUPPORTED_KEYWORDS`], nests deeper than [`MAX_DEPTH`], or gives a
377    /// keyword a value of the wrong shape.
378    pub fn compile(value: &Value) -> Result<Self, SchemaError> {
379        let root = compile_node(value, "", 0)?;
380        let bytes = boundgraph_core::to_canonical_cbor(value)
381            .map_err(|e| SchemaError::Encode(e.to_string()))?;
382        Ok(Self {
383            root,
384            source: value.clone(),
385            digest: Digest::of(&bytes),
386        })
387    }
388
389    /// The schema's content address.
390    ///
391    /// Recorded in every piece of evidence this schema produces, so a verdict
392    /// says which schema it was reached against. Two schemas that differ by so
393    /// much as a whitespace-insensitive key ordering share a digest; two that
394    /// differ in a constraint do not.
395    pub const fn digest(&self) -> Digest {
396        self.digest
397    }
398
399    /// The schema as it was given.
400    pub const fn source(&self) -> &Value {
401        &self.source
402    }
403
404    /// Validate a value, with the default step budget.
405    pub fn validate(&self, value: &Value) -> Validation {
406        self.validate_within(value, DEFAULT_BUDGET)
407    }
408
409    /// Validate a value, giving up after `budget` comparisons.
410    ///
411    /// See the module documentation for why exhaustion is its own outcome.
412    pub fn validate_within(&self, value: &Value, budget: usize) -> Validation {
413        let mut ctx = Ctx {
414            remaining: budget,
415            violations: Vec::new(),
416            gave_up: false,
417        };
418
419        check(&self.root, value, "", &mut ctx);
420
421        if ctx.gave_up {
422            Validation::Exhausted { budget }
423        } else if ctx.violations.is_empty() {
424            Validation::Valid
425        } else {
426            Validation::Invalid(ctx.violations)
427        }
428    }
429}
430
431/// Validation state: what is left of the budget, what has gone wrong, and
432/// whether the budget ran out.
433///
434/// Once `gave_up` is set every `check` returns immediately, so the walk unwinds
435/// without doing further work and without every call site having to thread a
436/// result back.
437struct Ctx {
438    remaining: usize,
439    violations: Vec<Violation>,
440    gave_up: bool,
441}
442
443impl Ctx {
444    /// Spend one step, giving up if the budget is gone.
445    fn step(&mut self) -> bool {
446        if self.gave_up {
447            return false;
448        }
449        match self.remaining.checked_sub(1) {
450            Some(left) => {
451                self.remaining = left;
452                true
453            }
454            None => {
455                self.gave_up = true;
456                false
457            }
458        }
459    }
460
461    fn fail(&mut self, path: &str, constraint: &'static str, detail: impl Into<String>) {
462        self.violations.push(Violation {
463            path: path.to_string(),
464            constraint,
465            detail: detail.into(),
466        });
467    }
468}
469
470/// Compile one schema node.
471fn compile_node(value: &Value, path: &str, depth: usize) -> Result<Node, SchemaError> {
472    if depth > MAX_DEPTH {
473        return Err(SchemaError::TooDeep {
474            path: path.to_string(),
475        });
476    }
477
478    let object = match value {
479        Value::Bool(accept) => {
480            return Ok(Node {
481                boolean: Some(*accept),
482                ..Node::default()
483            });
484        }
485        Value::Object(map) => map,
486        other => {
487            return Err(SchemaError::NotASchema {
488                path: path.to_string(),
489                found: type_of(other).to_string(),
490            });
491        }
492    };
493
494    let malformed = |keyword: &str, expected: &str| SchemaError::MalformedKeyword {
495        path: path.to_string(),
496        keyword: keyword.to_string(),
497        expected: expected.to_string(),
498    };
499
500    let mut node = Node::default();
501
502    for (keyword, arg) in object {
503        if ANNOTATIONS.contains(&keyword.as_str()) {
504            continue;
505        }
506        if !SUPPORTED_KEYWORDS.contains(&keyword.as_str()) {
507            return Err(SchemaError::UnsupportedKeyword {
508                path: path.to_string(),
509                keyword: keyword.clone(),
510            });
511        }
512
513        let at = |segment: &str| format!("{path}/{keyword}{segment}");
514
515        match keyword.as_str() {
516            "type" => {
517                let names = match arg {
518                    Value::String(one) => vec![one.clone()],
519                    Value::Array(many) => many
520                        .iter()
521                        .map(|v| v.as_str().map(str::to_string))
522                        .collect::<Option<Vec<_>>>()
523                        .ok_or_else(|| malformed("type", "a type name or an array of them"))?,
524                    _ => return Err(malformed("type", "a type name or an array of them")),
525                };
526                let mut types = BTreeSet::new();
527                for name in names {
528                    types.insert(Type::parse(&name).ok_or_else(|| {
529                        malformed(
530                            "type",
531                            "one of null, boolean, object, array, number, integer, string",
532                        )
533                    })?);
534                }
535                node.types = Some(types);
536            }
537            "enum" => {
538                let values = arg
539                    .as_array()
540                    .ok_or_else(|| malformed("enum", "an array of values"))?;
541                node.enumeration = Some(values.clone());
542            }
543            "const" => node.constant = Some(arg.clone()),
544            "properties" => {
545                let map = arg
546                    .as_object()
547                    .ok_or_else(|| malformed("properties", "an object of schemas"))?;
548                for (name, sub) in map {
549                    node.properties.insert(
550                        name.clone(),
551                        compile_node(sub, &at(&format!("/{name}")), depth + 1)?,
552                    );
553                }
554            }
555            "required" => {
556                let names = arg
557                    .as_array()
558                    .and_then(|a| {
559                        a.iter()
560                            .map(|v| v.as_str().map(str::to_string))
561                            .collect::<Option<Vec<_>>>()
562                    })
563                    .ok_or_else(|| malformed("required", "an array of property names"))?;
564                node.required = names;
565            }
566            "additionalProperties" => {
567                node.additional = Some(match arg {
568                    Value::Bool(false) => Additional::Forbidden,
569                    other => Additional::Schema(Box::new(compile_node(other, &at(""), depth + 1)?)),
570                });
571            }
572            "items" => node.items = Some(Box::new(compile_node(arg, &at(""), depth + 1)?)),
573            "not" => node.not = Some(Box::new(compile_node(arg, &at(""), depth + 1)?)),
574            "allOf" | "anyOf" | "oneOf" => {
575                let branches = arg
576                    .as_array()
577                    .ok_or_else(|| malformed(keyword, "an array of schemas"))?;
578                let mut compiled = Vec::with_capacity(branches.len());
579                for (index, branch) in branches.iter().enumerate() {
580                    compiled.push(compile_node(branch, &at(&format!("/{index}")), depth + 1)?);
581                }
582                match keyword.as_str() {
583                    "allOf" => node.all_of = compiled,
584                    "anyOf" => node.any_of = compiled,
585                    _ => node.one_of = compiled,
586                }
587            }
588            "minProperties" | "maxProperties" | "minItems" | "maxItems" | "minLength"
589            | "maxLength" => {
590                let n = arg
591                    .as_u64()
592                    .ok_or_else(|| malformed(keyword, "a non-negative integer"))?
593                    as usize;
594                match keyword.as_str() {
595                    "minProperties" => node.min_properties = Some(n),
596                    "maxProperties" => node.max_properties = Some(n),
597                    "minItems" => node.min_items = Some(n),
598                    "maxItems" => node.max_items = Some(n),
599                    "minLength" => node.min_length = Some(n),
600                    _ => node.max_length = Some(n),
601                }
602            }
603            "uniqueItems" => {
604                node.unique_items = arg
605                    .as_bool()
606                    .ok_or_else(|| malformed("uniqueItems", "a boolean"))?;
607            }
608            "minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum" | "multipleOf" => {
609                let n = arg.as_f64().ok_or_else(|| malformed(keyword, "a number"))?;
610                match keyword.as_str() {
611                    "minimum" => node.minimum = Some(n),
612                    "maximum" => node.maximum = Some(n),
613                    "exclusiveMinimum" => node.exclusive_minimum = Some(n),
614                    "exclusiveMaximum" => node.exclusive_maximum = Some(n),
615                    _ => {
616                        if n <= 0.0 {
617                            return Err(malformed("multipleOf", "a number greater than zero"));
618                        }
619                        node.multiple_of = Some(n);
620                    }
621                }
622            }
623            _ => unreachable!("keyword {keyword} is in SUPPORTED_KEYWORDS but not handled"),
624        }
625    }
626
627    Ok(node)
628}
629
630/// Escape a property name for a JSON Pointer, per RFC 6901.
631fn pointer(path: &str, segment: &str) -> String {
632    format!("{path}/{}", segment.replace('~', "~0").replace('/', "~1"))
633}
634
635/// Validate `value` against `node`, recording violations.
636///
637/// Stops early once [`Ctx::gave_up`] is set, so the walk unwinds without every
638/// call site having to thread a result back.
639fn check(node: &Node, value: &Value, path: &str, ctx: &mut Ctx) {
640    // No instance-depth guard, and none is needed: this descends only into
641    // `node`'s children, and `compile_node` capped those at MAX_DEPTH.
642    if !ctx.step() {
643        return;
644    }
645
646    if let Some(accept) = node.boolean {
647        if !accept {
648            ctx.fail(path, "false", "this schema accepts nothing");
649        }
650        return;
651    }
652
653    if let Some(types) = &node.types
654        && !types.iter().any(|t| t.matches(value))
655    {
656        let names: Vec<&str> = types.iter().map(|t| t.name()).collect();
657        ctx.fail(
658            path,
659            "type",
660            format!("expected {}, found {}", names.join(" or "), type_of(value)),
661        );
662        // Every other keyword is type-specific, so continuing would produce a
663        // cascade of violations that all restate this one.
664        return;
665    }
666
667    if let Some(allowed) = &node.enumeration
668        && !allowed.contains(value)
669    {
670        ctx.fail(
671            path,
672            "enum",
673            format!(
674                "`{value}` is not one of the {} permitted values",
675                allowed.len()
676            ),
677        );
678    }
679
680    if let Some(expected) = &node.constant
681        && value != expected
682    {
683        ctx.fail(
684            path,
685            "const",
686            format!("expected `{expected}`, found `{value}`"),
687        );
688    }
689
690    match value {
691        Value::Object(map) => check_object(node, map, path, ctx),
692        Value::Array(items) => check_array(node, items, path, ctx),
693        Value::String(text) => check_string(node, text, path, ctx),
694        Value::Number(_) => {
695            if let Some(n) = value.as_f64() {
696                check_number(node, n, path, ctx);
697            }
698        }
699        _ => {}
700    }
701
702    check_combinators(node, value, path, ctx);
703}
704
705fn check_object(node: &Node, map: &serde_json::Map<String, Value>, path: &str, ctx: &mut Ctx) {
706    for name in &node.required {
707        if !ctx.step() {
708            return;
709        }
710        if !map.contains_key(name) {
711            ctx.fail(
712                path,
713                "required",
714                format!("missing required property `{name}`"),
715            );
716        }
717    }
718
719    if let Some(min) = node.min_properties
720        && map.len() < min
721    {
722        ctx.fail(
723            path,
724            "minProperties",
725            format!("expected at least {min} properties, found {}", map.len()),
726        );
727    }
728    if let Some(max) = node.max_properties
729        && map.len() > max
730    {
731        ctx.fail(
732            path,
733            "maxProperties",
734            format!("expected at most {max} properties, found {}", map.len()),
735        );
736    }
737
738    for (name, sub) in map {
739        if ctx.gave_up {
740            return;
741        }
742        let at = pointer(path, name);
743        if let Some(schema) = node.properties.get(name) {
744            check(schema, sub, &at, ctx);
745        } else {
746            match node.additional.as_ref() {
747                Some(Additional::Forbidden) => ctx.fail(
748                    path,
749                    "additionalProperties",
750                    format!("property `{name}` is not permitted here"),
751                ),
752                Some(Additional::Schema(schema)) => check(schema, sub, &at, ctx),
753                None => {}
754            }
755        }
756    }
757}
758
759fn check_array(node: &Node, items: &[Value], path: &str, ctx: &mut Ctx) {
760    if let Some(min) = node.min_items
761        && items.len() < min
762    {
763        ctx.fail(
764            path,
765            "minItems",
766            format!("expected at least {min} items, found {}", items.len()),
767        );
768    }
769    if let Some(max) = node.max_items
770        && items.len() > max
771    {
772        ctx.fail(
773            path,
774            "maxItems",
775            format!("expected at most {max} items, found {}", items.len()),
776        );
777    }
778
779    if node.unique_items {
780        for (index, item) in items.iter().enumerate() {
781            if !ctx.step() {
782                return;
783            }
784            if let Some(first) = items[..index].iter().position(|other| other == item) {
785                ctx.fail(
786                    &format!("{path}/{index}"),
787                    "uniqueItems",
788                    format!("duplicates the item at index {first}"),
789                );
790            }
791        }
792    }
793
794    if let Some(schema) = &node.items {
795        for (index, item) in items.iter().enumerate() {
796            if ctx.gave_up {
797                return;
798            }
799            check(schema, item, &format!("{path}/{index}"), ctx);
800        }
801    }
802}
803
804fn check_string(node: &Node, text: &str, path: &str, ctx: &mut Ctx) {
805    // Characters, not bytes: JSON Schema counts code points, and a length limit
806    // that counted UTF-8 bytes would reject a valid name in most of the world's
807    // scripts.
808    let length = text.chars().count();
809
810    if let Some(min) = node.min_length
811        && length < min
812    {
813        ctx.fail(
814            path,
815            "minLength",
816            format!("expected at least {min} characters, found {length}"),
817        );
818    }
819    if let Some(max) = node.max_length
820        && length > max
821    {
822        ctx.fail(
823            path,
824            "maxLength",
825            format!("expected at most {max} characters, found {length}"),
826        );
827    }
828}
829
830fn check_number(node: &Node, n: f64, path: &str, ctx: &mut Ctx) {
831    if let Some(min) = node.minimum
832        && n < min
833    {
834        ctx.fail(path, "minimum", format!("{n} is below the minimum {min}"));
835    }
836    if let Some(max) = node.maximum
837        && n > max
838    {
839        ctx.fail(path, "maximum", format!("{n} is above the maximum {max}"));
840    }
841    if let Some(min) = node.exclusive_minimum
842        && n <= min
843    {
844        ctx.fail(
845            path,
846            "exclusiveMinimum",
847            format!("{n} is not above the exclusive minimum {min}"),
848        );
849    }
850    if let Some(max) = node.exclusive_maximum
851        && n >= max
852    {
853        ctx.fail(
854            path,
855            "exclusiveMaximum",
856            format!("{n} is not below the exclusive maximum {max}"),
857        );
858    }
859    if let Some(step) = node.multiple_of {
860        let quotient = n / step;
861        // A tolerance rather than an exact remainder: `0.1` is not exactly
862        // representable, so `0.3 % 0.1` is not zero and a strict test would
863        // reject arithmetic every schema author considers correct.
864        let off = (quotient - quotient.round()).abs();
865        if off > 1e-9 {
866            ctx.fail(
867                path,
868                "multipleOf",
869                format!("{n} is not a multiple of {step}"),
870            );
871        }
872    }
873}
874
875fn check_combinators(node: &Node, value: &Value, path: &str, ctx: &mut Ctx) {
876    for branch in &node.all_of {
877        if ctx.gave_up {
878            return;
879        }
880        check(branch, value, path, ctx);
881    }
882
883    if !node.any_of.is_empty() {
884        let mut matched = false;
885        for branch in &node.any_of {
886            match matches_quietly(branch, value, path, ctx) {
887                None => return,
888                Some(true) => {
889                    matched = true;
890                    break;
891                }
892                Some(false) => {}
893            }
894        }
895        if !matched {
896            ctx.fail(
897                path,
898                "anyOf",
899                format!("matched none of the {} alternatives", node.any_of.len()),
900            );
901        }
902    }
903
904    if !node.one_of.is_empty() {
905        let mut matches = 0usize;
906        for branch in &node.one_of {
907            match matches_quietly(branch, value, path, ctx) {
908                None => return,
909                Some(true) => matches += 1,
910                Some(false) => {}
911            }
912        }
913        if matches != 1 {
914            ctx.fail(
915                path,
916                "oneOf",
917                format!(
918                    "matched {matches} of the {} alternatives, expected exactly one",
919                    node.one_of.len()
920                ),
921            );
922        }
923    }
924
925    if let Some(inner) = &node.not {
926        match matches_quietly(inner, value, path, ctx) {
927            None => (),
928            Some(true) => ctx.fail(path, "not", "matched a schema it must not match"),
929            Some(false) => {}
930        }
931    }
932}
933
934/// Whether a value matches a branch, without recording why it did not.
935///
936/// A failed `anyOf` branch is not an error the author needs to see — it is how
937/// alternation works. Only the alternation itself is reported. `None` means the
938/// budget ran out.
939fn matches_quietly(node: &Node, value: &Value, path: &str, ctx: &mut Ctx) -> Option<bool> {
940    let mut probe = Ctx {
941        remaining: ctx.remaining,
942        violations: Vec::new(),
943        gave_up: false,
944    };
945    check(node, value, path, &mut probe);
946
947    // The budget is shared, so a branch that spent it does not leave the next
948    // branch a fresh one.
949    ctx.remaining = probe.remaining;
950
951    if probe.gave_up {
952        ctx.gave_up = probe.gave_up;
953        return None;
954    }
955    Some(probe.violations.is_empty())
956}
957
958/// Rejects a condensate payload that does not match a declared schema.
959///
960/// # Example
961///
962/// ```
963/// use boundgraph_check::{Schema, SchemaChecker};
964/// use serde_json::json;
965///
966/// let schema = Schema::compile(&json!({
967///     "type": "object",
968///     "properties": { "severity": { "enum": ["low", "high"] } },
969///     "required": ["severity"],
970/// }))?;
971///
972/// assert!(schema.validate(&json!({ "severity": "high" })).is_valid());
973///
974/// let bad = schema.validate(&json!({ "severity": "catastrophic" }));
975/// assert!(!bad.is_valid());
976///
977/// let checker = SchemaChecker::new(schema);
978/// # let _ = checker;
979/// # Ok::<(), boundgraph_check::SchemaError>(())
980/// ```
981#[derive(Debug, Clone)]
982pub struct SchemaChecker {
983    schema: Schema,
984    id: CheckerId,
985    budget: usize,
986}
987
988impl SchemaChecker {
989    /// A checker enforcing `schema`, with the default step budget.
990    pub fn new(schema: Schema) -> Self {
991        Self {
992            schema,
993            id: CheckerId::new("json-schema", 1),
994            budget: DEFAULT_BUDGET,
995        }
996    }
997
998    /// Give up after `budget` comparisons rather than [`DEFAULT_BUDGET`].
999    #[must_use]
1000    pub fn within(mut self, budget: usize) -> Self {
1001        self.budget = budget;
1002        self
1003    }
1004
1005    /// Name it something more specific than `json-schema`, so a ledger says
1006    /// which contract was enforced.
1007    #[must_use]
1008    pub fn named(mut self, name: impl Into<String>, version: u32) -> Self {
1009        self.id = CheckerId::new(name, version);
1010        self
1011    }
1012
1013    /// The schema being enforced.
1014    pub const fn schema(&self) -> &Schema {
1015        &self.schema
1016    }
1017}
1018
1019#[async_trait]
1020impl Checker for SchemaChecker {
1021    fn id(&self) -> CheckerId {
1022        self.id.clone()
1023    }
1024
1025    // No grants: the payload arrives in the condensate, so this checker reads
1026    // nothing, runs nothing, and needs no authority at all.
1027
1028    async fn judge(&self, _order: &WorkOrder, condensate: &Condensate) -> Judgement {
1029        let schema_digest = self.schema.digest();
1030
1031        let payload = match &condensate.payload {
1032            CondensatePayload::Inline(value) => value,
1033            // Fetching a stored payload needs a content store, which this
1034            // checker deliberately does not hold — see US-0806, where the
1035            // checker that does need one is handed it explicitly.
1036            CondensatePayload::Stored(digest) => {
1037                return Judgement::inconclusive(format!(
1038                    "the condensate payload is stored at {digest} rather than inline, \
1039                     and this checker cannot fetch it"
1040                ))
1041                .covering(schema_digest);
1042            }
1043        };
1044
1045        // Both halves of the guarantee: representable as JSON, then valid.
1046        let json: Value = match payload.deserialized() {
1047            Ok(json) => json,
1048            Err(e) => {
1049                return Judgement::inconclusive(format!(
1050                    "the condensate payload is not representable as JSON: {e}"
1051                ))
1052                .covering(schema_digest);
1053            }
1054        };
1055
1056        let judgement = match self.schema.validate_within(&json, self.budget) {
1057            Validation::Valid => Judgement::pass(),
1058            Validation::Invalid(violations) => {
1059                let listed: Vec<String> = violations.iter().map(Violation::render).collect();
1060                Judgement::fail(format!(
1061                    "{} schema violation{}: {}",
1062                    listed.len(),
1063                    if listed.len() == 1 { "" } else { "s" },
1064                    listed.join("; ")
1065                ))
1066            }
1067            Validation::Exhausted { budget } => Judgement::inconclusive(format!(
1068                "validation did not finish within {budget} comparisons"
1069            )),
1070        };
1071
1072        judgement.covering(schema_digest)
1073    }
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079    use crate::sign::LocalKey;
1080    use boundgraph_core::{Digest, GrantSet, Intent, ProposalOrigin, SchemaId, SlicerId, Verdict};
1081    use serde_json::json;
1082
1083    fn schema(value: Value) -> Schema {
1084        Schema::compile(&value).expect("test schema should compile")
1085    }
1086
1087    fn order() -> WorkOrder {
1088        WorkOrder::new(
1089            Intent::new("report.emit", &"summarise the findings").unwrap(),
1090            vec![],
1091            GrantSet::EMPTY,
1092            ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
1093        )
1094    }
1095
1096    fn condensate(payload: &Value) -> Condensate {
1097        Condensate::new(
1098            SchemaId::new("boundgraph.json.v1", 1),
1099            CondensatePayload::inline(payload).unwrap(),
1100            Digest::of(b"the raw response"),
1101            400,
1102            false,
1103            SlicerId::new("json", 1),
1104        )
1105    }
1106
1107    fn violations(schema: &Schema, value: &Value) -> Vec<Violation> {
1108        match schema.validate(value) {
1109            Validation::Invalid(v) => v,
1110            other => panic!("expected violations, got {other:?}"),
1111        }
1112    }
1113
1114    // ── validation ──────────────────────────────────────────────────────────
1115
1116    #[test]
1117    fn a_matching_payload_is_valid() {
1118        let s = schema(json!({
1119            "type": "object",
1120            "properties": { "name": { "type": "string" }, "count": { "type": "integer" } },
1121            "required": ["name"],
1122        }));
1123
1124        assert!(
1125            s.validate(&json!({ "name": "auth.rs", "count": 3 }))
1126                .is_valid()
1127        );
1128    }
1129
1130    /// The criterion: a failure names the failing path and the constraint.
1131    #[test]
1132    fn a_violation_names_the_path_and_the_constraint() {
1133        let s = schema(json!({
1134            "type": "object",
1135            "properties": {
1136                "items": { "type": "array", "items": { "type": "string" } },
1137            },
1138        }));
1139
1140        let found = violations(&s, &json!({ "items": ["ok", "also ok", 42] }));
1141
1142        assert_eq!(found.len(), 1);
1143        assert_eq!(found[0].path, "/items/2");
1144        assert_eq!(found[0].constraint, "type");
1145        assert!(found[0].detail.contains("expected string"));
1146        assert!(found[0].detail.contains("found number"));
1147    }
1148
1149    #[test]
1150    fn a_missing_required_property_names_it() {
1151        let s = schema(json!({ "type": "object", "required": ["email"] }));
1152        let found = violations(&s, &json!({ "name": "alex" }));
1153
1154        assert_eq!(found[0].constraint, "required");
1155        assert!(found[0].detail.contains("email"));
1156    }
1157
1158    #[test]
1159    fn a_property_name_with_a_slash_is_escaped_in_the_pointer() {
1160        let s = schema(json!({
1161            "type": "object",
1162            "properties": { "a/b": { "type": "string" } },
1163        }));
1164
1165        let found = violations(&s, &json!({ "a/b": 1 }));
1166        assert_eq!(found[0].path, "/a~1b");
1167    }
1168
1169    #[test]
1170    fn the_root_renders_as_root_rather_than_an_empty_path() {
1171        let s = schema(json!({ "type": "string" }));
1172        let found = violations(&s, &json!(1));
1173
1174        assert_eq!(found[0].path, "");
1175        assert!(found[0].render().starts_with("(root)"));
1176    }
1177
1178    #[test]
1179    fn every_supported_keyword_is_enforced() {
1180        let cases: &[(Value, Value, &str)] = &[
1181            (json!({ "type": "string" }), json!(1), "type"),
1182            (json!({ "enum": ["a", "b"] }), json!("c"), "enum"),
1183            (json!({ "const": 7 }), json!(8), "const"),
1184            (
1185                json!({ "type": "object", "required": ["x"] }),
1186                json!({}),
1187                "required",
1188            ),
1189            (
1190                json!({ "type": "object", "properties": { "x": true }, "additionalProperties": false }),
1191                json!({ "y": 1 }),
1192                "additionalProperties",
1193            ),
1194            (
1195                json!({ "type": "object", "minProperties": 2 }),
1196                json!({ "x": 1 }),
1197                "minProperties",
1198            ),
1199            (
1200                json!({ "type": "object", "maxProperties": 1 }),
1201                json!({ "x": 1, "y": 2 }),
1202                "maxProperties",
1203            ),
1204            (
1205                json!({ "type": "array", "minItems": 2 }),
1206                json!([1]),
1207                "minItems",
1208            ),
1209            (
1210                json!({ "type": "array", "maxItems": 1 }),
1211                json!([1, 2]),
1212                "maxItems",
1213            ),
1214            (
1215                json!({ "type": "array", "uniqueItems": true }),
1216                json!([1, 1]),
1217                "uniqueItems",
1218            ),
1219            (json!({ "minimum": 5 }), json!(4), "minimum"),
1220            (json!({ "maximum": 5 }), json!(6), "maximum"),
1221            (
1222                json!({ "exclusiveMinimum": 5 }),
1223                json!(5),
1224                "exclusiveMinimum",
1225            ),
1226            (
1227                json!({ "exclusiveMaximum": 5 }),
1228                json!(5),
1229                "exclusiveMaximum",
1230            ),
1231            (json!({ "multipleOf": 3 }), json!(7), "multipleOf"),
1232            (json!({ "minLength": 3 }), json!("ab"), "minLength"),
1233            (json!({ "maxLength": 1 }), json!("ab"), "maxLength"),
1234            (
1235                json!({ "allOf": [{ "type": "string" }, { "minLength": 5 }] }),
1236                json!("abc"),
1237                "minLength",
1238            ),
1239            (
1240                json!({ "anyOf": [{ "type": "string" }, { "type": "number" }] }),
1241                json!(true),
1242                "anyOf",
1243            ),
1244            (
1245                json!({ "oneOf": [{ "type": "number" }, { "type": "integer" }] }),
1246                json!(2),
1247                "oneOf",
1248            ),
1249            (json!({ "not": { "type": "string" } }), json!("x"), "not"),
1250        ];
1251
1252        for (schema_value, instance, constraint) in cases {
1253            let found = violations(&schema(schema_value.clone()), instance);
1254            assert!(
1255                found.iter().any(|v| v.constraint == *constraint),
1256                "{schema_value} over {instance} should report `{constraint}`, got {found:?}"
1257            );
1258        }
1259    }
1260
1261    #[test]
1262    fn integer_accepts_a_whole_number_written_as_a_float() {
1263        let s = schema(json!({ "type": "integer" }));
1264        assert!(s.validate(&json!(2.0)).is_valid());
1265        assert!(!s.validate(&json!(2.5)).is_valid());
1266    }
1267
1268    #[test]
1269    fn string_lengths_count_characters_not_bytes() {
1270        let s = schema(json!({ "type": "string", "maxLength": 3 }));
1271        assert!(!s.validate(&json!("héllo")).is_valid());
1272        assert!(s.validate(&json!("héo")).is_valid());
1273    }
1274
1275    #[test]
1276    fn multiple_of_tolerates_ordinary_decimal_arithmetic() {
1277        let s = schema(json!({ "multipleOf": 0.1 }));
1278        assert!(s.validate(&json!(0.3)).is_valid());
1279        assert!(!s.validate(&json!(0.35)).is_valid());
1280    }
1281
1282    #[test]
1283    fn a_boolean_schema_accepts_or_refuses_everything() {
1284        assert!(
1285            schema(json!(true))
1286                .validate(&json!({ "anything": 1 }))
1287                .is_valid()
1288        );
1289        assert!(!schema(json!(false)).validate(&json!(null)).is_valid());
1290    }
1291
1292    /// A wrong type makes every other keyword meaningless, so reporting them
1293    /// too would bury the one violation that matters.
1294    #[test]
1295    fn a_type_mismatch_does_not_cascade() {
1296        let s = schema(json!({
1297            "type": "object",
1298            "required": ["a", "b", "c"],
1299            "minProperties": 3,
1300        }));
1301
1302        let found = violations(&s, &json!("not an object at all"));
1303        assert_eq!(found.len(), 1);
1304        assert_eq!(found[0].constraint, "type");
1305    }
1306
1307    #[test]
1308    fn every_violation_is_reported_not_just_the_first() {
1309        let s = schema(json!({
1310            "type": "object",
1311            "properties": { "a": { "type": "string" }, "b": { "type": "string" } },
1312            "required": ["c"],
1313        }));
1314
1315        let found = violations(&s, &json!({ "a": 1, "b": 2 }));
1316        assert_eq!(found.len(), 3, "two type errors and one missing property");
1317    }
1318
1319    /// A failed alternative is how alternation works, not something the author
1320    /// needs to read.
1321    #[test]
1322    fn a_failed_any_of_branch_is_not_reported_separately() {
1323        let s = schema(json!({ "anyOf": [{ "type": "string" }, { "type": "number" }] }));
1324        let found = violations(&s, &json!(true));
1325
1326        assert_eq!(found.len(), 1);
1327        assert_eq!(found[0].constraint, "anyOf");
1328    }
1329
1330    // ── compiling ───────────────────────────────────────────────────────────
1331
1332    /// The criterion behind the whole subset decision: an unimplemented
1333    /// keyword is refused, never silently skipped.
1334    #[test]
1335    fn an_unsupported_keyword_is_refused_rather_than_ignored() {
1336        for keyword in [
1337            "$ref",
1338            "pattern",
1339            "patternProperties",
1340            "format",
1341            "if",
1342            "contains",
1343            "propertyNames",
1344            "prefixItems",
1345        ] {
1346            let err = Schema::compile(&json!({ keyword: "whatever" })).unwrap_err();
1347            let SchemaError::UnsupportedKeyword { keyword: named, .. } = err else {
1348                panic!("`{keyword}` should be refused, got {err:?}");
1349            };
1350            assert_eq!(named, keyword);
1351        }
1352    }
1353
1354    #[test]
1355    fn an_unsupported_keyword_is_found_however_deeply_it_is_nested() {
1356        let err = Schema::compile(&json!({
1357            "type": "object",
1358            "properties": { "user": { "properties": { "email": { "format": "email" } } } },
1359        }))
1360        .unwrap_err();
1361
1362        let SchemaError::UnsupportedKeyword { path, keyword } = err else {
1363            panic!("expected UnsupportedKeyword, got {err:?}");
1364        };
1365        assert_eq!(keyword, "format");
1366        assert!(path.contains("user"), "the path should locate it: {path}");
1367    }
1368
1369    #[test]
1370    fn annotations_are_skipped_rather_than_refused() {
1371        let s = Schema::compile(&json!({
1372            "$schema": "https://json-schema.org/draft/2020-12/schema",
1373            "title": "A finding",
1374            "description": "what the step found",
1375            "type": "object",
1376        }));
1377        assert!(s.is_ok(), "{s:?}");
1378    }
1379
1380    #[test]
1381    fn a_malformed_keyword_says_what_it_should_have_been() {
1382        let err = Schema::compile(&json!({ "required": "email" })).unwrap_err();
1383        let SchemaError::MalformedKeyword {
1384            keyword, expected, ..
1385        } = err
1386        else {
1387            panic!("expected MalformedKeyword, got {err:?}");
1388        };
1389        assert_eq!(keyword, "required");
1390        assert!(expected.contains("array"));
1391    }
1392
1393    #[test]
1394    fn an_unknown_type_name_is_refused() {
1395        assert!(Schema::compile(&json!({ "type": "struct" })).is_err());
1396    }
1397
1398    #[test]
1399    fn a_multiple_of_zero_is_refused_rather_than_dividing_by_it() {
1400        assert!(Schema::compile(&json!({ "multipleOf": 0 })).is_err());
1401    }
1402
1403    #[test]
1404    fn something_that_is_not_a_schema_at_all_is_refused() {
1405        let err = Schema::compile(&json!(["a", "list"])).unwrap_err();
1406        assert!(matches!(err, SchemaError::NotASchema { .. }));
1407    }
1408
1409    #[test]
1410    fn a_schema_nested_past_the_depth_limit_is_refused() {
1411        let mut value = json!({ "type": "string" });
1412        for _ in 0..(MAX_DEPTH + 2) {
1413            value = json!({ "type": "object", "properties": { "next": value } });
1414        }
1415
1416        assert!(matches!(
1417            Schema::compile(&value),
1418            Err(SchemaError::TooDeep { .. })
1419        ));
1420    }
1421
1422    #[test]
1423    fn schema_text_that_is_not_json_is_a_typed_error() {
1424        assert!(matches!(
1425            Schema::parse("{not json"),
1426            Err(SchemaError::NotJson(_))
1427        ));
1428    }
1429
1430    // ── content addressing ──────────────────────────────────────────────────
1431
1432    /// The criterion: the schema is content-addressed.
1433    #[test]
1434    fn the_same_schema_always_has_the_same_digest() {
1435        let a = schema(json!({ "type": "object", "required": ["x"] }));
1436        let b = Schema::parse(r#"{"required": ["x"], "type": "object"}"#).unwrap();
1437
1438        assert_eq!(a.digest(), b.digest(), "key order is not part of a schema");
1439    }
1440
1441    #[test]
1442    fn a_different_constraint_is_a_different_digest() {
1443        let a = schema(json!({ "type": "object", "required": ["x"] }));
1444        let b = schema(json!({ "type": "object", "required": ["y"] }));
1445
1446        assert_ne!(a.digest(), b.digest());
1447    }
1448
1449    // ── bounded validation ──────────────────────────────────────────────────
1450
1451    /// The criterion: validation is bounded and cannot hang.
1452    #[test]
1453    fn running_out_of_budget_is_neither_a_pass_nor_a_failure() {
1454        let s = schema(json!({ "type": "array", "items": { "type": "integer" } }));
1455        let big = Value::Array((0..10_000).map(|n| json!(n)).collect());
1456
1457        let outcome = s.validate_within(&big, 50);
1458
1459        assert_eq!(outcome, Validation::Exhausted { budget: 50 });
1460        assert!(!outcome.is_valid());
1461    }
1462
1463    /// Recursion follows the schema, not the instance. A two-level schema walks
1464    /// two levels however deeply the payload nests, which is why refusing
1465    /// `$ref` is a termination argument and not merely a scope decision.
1466    #[test]
1467    fn a_deeply_nested_instance_is_walked_only_as_deep_as_the_schema() {
1468        let s = schema(json!({
1469            "type": "object",
1470            "additionalProperties": { "type": "object" },
1471        }));
1472
1473        let mut instance = json!({});
1474        for _ in 0..1_000 {
1475            instance = json!({ "next": instance });
1476        }
1477
1478        // Two levels of descent, so a handful of steps — not one per level.
1479        let outcome = s.validate_within(&instance, 16);
1480        assert!(outcome.is_valid(), "{outcome:?}");
1481    }
1482
1483    #[test]
1484    fn nested_alternation_over_a_large_instance_gives_up_rather_than_grinding() {
1485        let branch = json!({
1486            "oneOf": [
1487                { "type": "object", "additionalProperties": { "type": "integer" } },
1488                { "type": "object", "additionalProperties": { "type": "number" } },
1489                { "type": "object", "additionalProperties": { "type": "string" } },
1490            ],
1491        });
1492        let s = schema(json!({ "type": "array", "items": branch }));
1493
1494        let big = Value::Array((0..2_000).map(|n| json!({ "k": n })).collect());
1495        assert!(matches!(
1496            s.validate_within(&big, 200),
1497            Validation::Exhausted { .. }
1498        ));
1499    }
1500
1501    // ── the checker ─────────────────────────────────────────────────────────
1502
1503    #[tokio::test]
1504    async fn a_matching_payload_passes_and_the_schema_digest_is_in_the_evidence() {
1505        let s = schema(json!({
1506            "type": "object",
1507            "properties": { "severity": { "enum": ["low", "high"] } },
1508            "required": ["severity"],
1509        }));
1510        let digest = s.digest();
1511
1512        let evidence = SchemaChecker::new(s)
1513            .check(
1514                &order(),
1515                &condensate(&json!({ "severity": "high" })),
1516                &LocalKey::from_seed("k", [1u8; 32]),
1517            )
1518            .await
1519            .unwrap();
1520
1521        assert!(evidence.permits_commit());
1522        assert!(
1523            evidence.covers(&digest),
1524            "the evidence must name the schema it was reached against"
1525        );
1526    }
1527
1528    #[tokio::test]
1529    async fn a_violating_payload_fails_with_the_path_in_the_reason() {
1530        let s = schema(json!({
1531            "type": "object",
1532            "properties": { "count": { "type": "integer" } },
1533        }));
1534
1535        let evidence = SchemaChecker::new(s)
1536            .check(
1537                &order(),
1538                &condensate(&json!({ "count": "three" })),
1539                &LocalKey::from_seed("k", [1u8; 32]),
1540            )
1541            .await
1542            .unwrap();
1543
1544        assert!(!evidence.permits_commit());
1545        let reason = evidence.verdict.reason().unwrap();
1546        assert!(reason.contains("/count"), "{reason}");
1547        assert!(reason.contains("type"), "{reason}");
1548    }
1549
1550    #[tokio::test]
1551    async fn a_stored_payload_is_inconclusive_rather_than_a_pass() {
1552        let s = schema(json!({ "type": "object" }));
1553        let mut k = condensate(&json!({}));
1554        k.payload = CondensatePayload::Stored(Digest::of(b"elsewhere"));
1555
1556        let evidence = SchemaChecker::new(s)
1557            .check(&order(), &k, &LocalKey::from_seed("k", [1u8; 32]))
1558            .await
1559            .unwrap();
1560
1561        assert!(!evidence.permits_commit());
1562        assert!(matches!(evidence.verdict, Verdict::Inconclusive(_)));
1563    }
1564
1565    #[tokio::test]
1566    async fn an_exhausted_budget_is_inconclusive_rather_than_a_pass() {
1567        let s = schema(json!({ "type": "array", "items": { "type": "integer" } }));
1568        let big = Value::Array((0..5_000).map(|n| json!(n)).collect());
1569
1570        let evidence = SchemaChecker::new(s)
1571            .within(10)
1572            .check(
1573                &order(),
1574                &condensate(&big),
1575                &LocalKey::from_seed("k", [1u8; 32]),
1576            )
1577            .await
1578            .unwrap();
1579
1580        assert!(matches!(evidence.verdict, Verdict::Inconclusive(_)));
1581    }
1582
1583    /// The criterion: a passing verdict means a downstream step can
1584    /// deserialise the payload.
1585    #[tokio::test]
1586    async fn a_pass_means_the_payload_deserialises_downstream() {
1587        #[derive(serde::Deserialize)]
1588        struct Finding {
1589            #[allow(dead_code)]
1590            severity: String,
1591            #[allow(dead_code)]
1592            files: Vec<String>,
1593        }
1594
1595        let s = schema(json!({
1596            "type": "object",
1597            "properties": {
1598                "severity": { "type": "string" },
1599                "files": { "type": "array", "items": { "type": "string" } },
1600            },
1601            "required": ["severity", "files"],
1602        }));
1603
1604        let payload = json!({ "severity": "high", "files": ["src/auth.rs"] });
1605        let k = condensate(&payload);
1606
1607        let evidence = SchemaChecker::new(s)
1608            .check(&order(), &k, &LocalKey::from_seed("k", [1u8; 32]))
1609            .await
1610            .unwrap();
1611        assert!(evidence.permits_commit());
1612
1613        let CondensatePayload::Inline(value) = &k.payload else {
1614            unreachable!()
1615        };
1616        let parsed: Result<Finding, _> = value.deserialized();
1617        assert!(parsed.is_ok(), "a pass must mean this cannot fail");
1618    }
1619
1620    #[tokio::test]
1621    async fn the_checker_needs_no_authority_at_all() {
1622        assert_eq!(
1623            SchemaChecker::new(schema(json!(true))).grants(),
1624            GrantSet::EMPTY
1625        );
1626    }
1627
1628    #[tokio::test]
1629    async fn the_same_payload_always_gives_the_same_evidence() {
1630        let checker = SchemaChecker::new(schema(json!({ "type": "object" })));
1631        let (order, k, key) = (
1632            order(),
1633            condensate(&json!({})),
1634            LocalKey::from_seed("k", [1u8; 32]),
1635        );
1636
1637        let first = checker.check(&order, &k, &key).await.unwrap();
1638        for _ in 0..8 {
1639            assert_eq!(checker.check(&order, &k, &key).await.unwrap(), first);
1640        }
1641    }
1642}