Skip to main content

mif_schema/
lib.rs

1//! JSON Schema validation for the MIF (Modeled Information Format) ecosystem.
2//!
3//! Validates MIF documents and citation objects against the canonical MIF
4//! JSON Schema (draft 2020-12; see <https://mif-spec.dev/schema/>). Schemas
5//! are vendored at compile time (`src/schemas/`, synced from the canonical
6//! `MIF` repo) and resolved entirely offline — no network access happens at
7//! validation time.
8
9use std::sync::OnceLock;
10
11use jsonschema::{Registry, Validator};
12use mif_problem::{
13    Applicability, CodeAction, ProblemDetails, ProblemMeta, SuggestedFix, ToProblem,
14};
15use serde_json::Value;
16
17const MIF_SCHEMA: &str = include_str!("schemas/mif.schema.json");
18const CITATION_SCHEMA: &str = include_str!("schemas/citation.schema.json");
19const ONTOLOGY_SCHEMA: &str = include_str!("schemas/ontology.schema.json");
20const ENTITY_REFERENCE_SCHEMA: &str =
21    include_str!("schemas/definitions/entity-reference.schema.json");
22const ENTITY_REFERENCE_SCHEMA_ID: &str =
23    "https://mif-spec.dev/schema/definitions/entity-reference.schema.json";
24
25/// The original `serde_json`/`jsonschema` error message behind a
26/// [`MifSchemaError::SchemaCompilation`] failure.
27///
28/// `serde_json::Error` and `jsonschema`'s build/registry errors are
29/// stringified before being cached (see `build_registry`/`build_validator`),
30/// so this wrapper is what `#[source]` actually points at: it preserves the
31/// original error's message so `std::error::Error::source()` on
32/// `SchemaCompilation` yields a real hop in the chain instead of `None`.
33/// This wrapper's own `source()` returns `None` — the original typed error
34/// itself could not be preserved through the cache.
35#[derive(Debug)]
36pub struct SchemaCompilationSource(String);
37
38impl std::fmt::Display for SchemaCompilationSource {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.write_str(&self.0)
41    }
42}
43
44impl std::error::Error for SchemaCompilationSource {}
45
46/// A MIF level floor (L1/L2/L3 conformance tier).
47///
48/// Level floors are additive: L2 requires everything L1 requires plus its
49/// own fields, and L3 requires everything L2 requires plus its own. L1's
50/// fields (`id`/`@id`, `type`/`conceptType`, `created`) are already enforced
51/// by the canonical core schema's `required` list, so [`validate_level`]
52/// with [`Level::L1`] is equivalent to [`validate_document`]; L2 and L3 add
53/// genuinely new checks beyond the core schema.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum Level {
56    /// `id`, `type`, `created` — already enforced by the core schema.
57    L1,
58    /// Adds `namespace`, `modified`, `temporal`.
59    L2,
60    /// Adds `provenance` and a non-null `temporal.validFrom`.
61    L3,
62}
63
64impl Level {
65    const fn as_u8(self) -> u8 {
66        match self {
67            Self::L1 => 1,
68            Self::L2 => 2,
69            Self::L3 => 3,
70        }
71    }
72}
73
74impl std::fmt::Display for Level {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        write!(f, "L{}", self.as_u8())
77    }
78}
79
80impl TryFrom<u8> for Level {
81    type Error = MifSchemaError;
82
83    /// Converts a raw level number into a [`Level`].
84    ///
85    /// # Errors
86    ///
87    /// Returns [`MifSchemaError::UnsupportedLevel`] if `value` is not 1, 2, or 3.
88    fn try_from(value: u8) -> Result<Self, Self::Error> {
89        match value {
90            1 => Ok(Self::L1),
91            2 => Ok(Self::L2),
92            3 => Ok(Self::L3),
93            other => Err(MifSchemaError::UnsupportedLevel(other)),
94        }
95    }
96}
97
98/// Error validating a MIF document or citation against the canonical schema.
99#[derive(Debug, thiserror::Error)]
100pub enum MifSchemaError {
101    /// The vendored schema itself failed to compile. Indicates a bug in
102    /// this crate's vendored schema files, not a problem with the instance
103    /// being validated.
104    #[error("internal error: vendored MIF schema failed to compile: {0}")]
105    SchemaCompilation(#[source] SchemaCompilationSource),
106    /// The instance failed schema validation.
107    #[error("MIF document failed schema validation: {}", .0.join("; "))]
108    Invalid(Vec<String>),
109    /// The instance passed core schema validation but is missing fields the
110    /// requested [`Level`] floor requires.
111    #[error(
112        "document does not satisfy the {level} level floor: missing {}",
113        .missing.join(", ")
114    )]
115    LevelFloorViolation {
116        /// The level floor that was requested.
117        level: Level,
118        /// The missing field names (dotted paths for nested fields, e.g.
119        /// `temporal.validFrom`).
120        missing: Vec<String>,
121    },
122    /// The requested level number is not a valid MIF level floor.
123    #[error("unsupported MIF level: {0} (must be 1, 2, or 3)")]
124    UnsupportedLevel(u8),
125}
126
127impl MifSchemaError {
128    /// The individual validation error messages if this is
129    /// [`MifSchemaError::Invalid`], or the missing field names (not full
130    /// messages) if this is [`MifSchemaError::LevelFloorViolation`]. Empty
131    /// for [`MifSchemaError::SchemaCompilation`] and
132    /// [`MifSchemaError::UnsupportedLevel`].
133    #[must_use]
134    pub fn messages(&self) -> &[String] {
135        match self {
136            Self::Invalid(errors)
137            | Self::LevelFloorViolation {
138                missing: errors, ..
139            } => errors,
140            Self::SchemaCompilation(_) | Self::UnsupportedLevel(_) => &[],
141        }
142    }
143
144    const fn meta(&self) -> ProblemMeta {
145        match self {
146            Self::SchemaCompilation(_) => ProblemMeta {
147                slug: "schema-compilation",
148                version: "v1",
149                title: "Internal schema compilation error",
150                status: 500,
151                exit_code: 1,
152            },
153            Self::Invalid(_) => ProblemMeta {
154                slug: "invalid-document",
155                version: "v1",
156                title: "Document failed schema validation",
157                status: 422,
158                exit_code: 2,
159            },
160            Self::LevelFloorViolation { .. } => ProblemMeta {
161                slug: "level-floor-violation",
162                version: "v1",
163                title: "Document does not satisfy the requested level floor",
164                status: 422,
165                exit_code: 5,
166            },
167            Self::UnsupportedLevel(_) => ProblemMeta {
168                slug: "unsupported-level",
169                version: "v1",
170                title: "Unsupported MIF level",
171                status: 400,
172                exit_code: 2,
173            },
174        }
175    }
176}
177
178impl ToProblem for MifSchemaError {
179    fn to_problem(&self) -> ProblemDetails {
180        let (fix, action) = match self {
181            Self::SchemaCompilation(_) => (
182                SuggestedFix::new(
183                    "This indicates a bug in mif-schema's vendored schema files, not the \
184                     instance being validated. Report it upstream.",
185                    Applicability::Unspecified,
186                ),
187                CodeAction::new(
188                    "File a bug against mif-schema's vendored schemas",
189                    "quickfix",
190                    Applicability::Unspecified,
191                ),
192            ),
193            Self::Invalid(_) => (
194                SuggestedFix::new(
195                    "Correct the document so it conforms to the canonical MIF JSON Schema, \
196                     then retry.",
197                    Applicability::MaybeIncorrect,
198                ),
199                CodeAction::new(
200                    "Fix the reported schema violations",
201                    "quickfix",
202                    Applicability::MaybeIncorrect,
203                ),
204            ),
205            Self::LevelFloorViolation { .. } => (
206                SuggestedFix::new(
207                    "Add the missing fields the requested level floor requires, then retry.",
208                    Applicability::MaybeIncorrect,
209                ),
210                CodeAction::new(
211                    "Add the missing level-floor fields",
212                    "quickfix",
213                    Applicability::MaybeIncorrect,
214                ),
215            ),
216            Self::UnsupportedLevel(_) => (
217                SuggestedFix::new(
218                    "Request level 1, 2, or 3.",
219                    Applicability::MachineApplicable,
220                ),
221                CodeAction::new(
222                    "Use a supported level (1, 2, or 3)",
223                    "quickfix",
224                    Applicability::MachineApplicable,
225                ),
226            ),
227        };
228        self.meta()
229            .into_details(env!("CARGO_PKG_NAME"), self.to_string())
230            .with_suggested_fix(fix)
231            .with_code_action(action)
232    }
233}
234
235fn build_registry() -> Result<Registry<'static>, String> {
236    let entity_reference: Value =
237        serde_json::from_str(ENTITY_REFERENCE_SCHEMA).map_err(|e| e.to_string())?;
238    Registry::new()
239        .add(ENTITY_REFERENCE_SCHEMA_ID, entity_reference)
240        .map_err(|e| e.to_string())?
241        .prepare()
242        .map_err(|e| e.to_string())
243}
244
245fn build_validator(schema_json: &str) -> Result<Validator, String> {
246    let schema: Value = serde_json::from_str(schema_json).map_err(|e| e.to_string())?;
247    let registry = build_registry()?;
248    jsonschema::options()
249        .with_registry(&registry)
250        .build(&schema)
251        .map_err(|e| e.to_string())
252}
253
254fn document_validator() -> Result<&'static Validator, MifSchemaError> {
255    static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
256    VALIDATOR
257        .get_or_init(|| build_validator(MIF_SCHEMA))
258        .as_ref()
259        .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
260}
261
262fn citation_validator() -> Result<&'static Validator, MifSchemaError> {
263    static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
264    VALIDATOR
265        .get_or_init(|| build_validator(CITATION_SCHEMA))
266        .as_ref()
267        .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
268}
269
270fn ontology_validator() -> Result<&'static Validator, MifSchemaError> {
271    static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
272    VALIDATOR
273        .get_or_init(|| build_validator(ONTOLOGY_SCHEMA))
274        .as_ref()
275        .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
276}
277
278fn validate(validator: &Validator, instance: &Value) -> Result<(), MifSchemaError> {
279    let errors: Vec<String> = validator
280        .iter_errors(instance)
281        .map(|error| error.to_string())
282        .collect();
283    if errors.is_empty() {
284        Ok(())
285    } else {
286        Err(MifSchemaError::Invalid(errors))
287    }
288}
289
290/// Validates a MIF document (a JSON-LD-projected memory) against the
291/// canonical `mif.schema.json`.
292///
293/// # Errors
294///
295/// Returns [`MifSchemaError::Invalid`] with every validation error message
296/// if `instance` does not conform to the schema, or
297/// [`MifSchemaError::SchemaCompilation`] if the vendored schema itself
298/// fails to compile (indicates a bug in this crate).
299pub fn validate_document(instance: &Value) -> Result<(), MifSchemaError> {
300    validate(document_validator()?, instance)
301}
302
303/// Returns `true` if `instance` has a present, non-null value at `key`.
304fn has_non_null_field(instance: &Value, key: &str) -> bool {
305    instance.get(key).is_some_and(|value| !value.is_null())
306}
307
308/// The field names (dotted paths for nested fields) that `instance` is
309/// missing relative to `level`'s floor, beyond what the core schema already
310/// requires. Empty if `instance` already satisfies the floor.
311fn missing_level_fields(instance: &Value, level: Level) -> Vec<String> {
312    let mut missing = Vec::new();
313    if level.as_u8() >= Level::L2.as_u8() {
314        for field in ["namespace", "modified", "temporal"] {
315            if !has_non_null_field(instance, field) {
316                missing.push(field.to_string());
317            }
318        }
319    }
320    if level.as_u8() >= Level::L3.as_u8() {
321        if !has_non_null_field(instance, "provenance") {
322            missing.push("provenance".to_string());
323        }
324        let has_valid_from = instance
325            .get("temporal")
326            .and_then(|temporal| temporal.get("validFrom"))
327            .is_some_and(|value| !value.is_null());
328        if !has_valid_from {
329            missing.push("temporal.validFrom".to_string());
330        }
331    }
332    missing
333}
334
335/// Validates a MIF document (a JSON-LD-projected memory) against the
336/// canonical `mif.schema.json`, then against the additional fields the
337/// requested [`Level`] floor requires.
338///
339/// # Errors
340///
341/// Returns [`MifSchemaError::Invalid`] or [`MifSchemaError::SchemaCompilation`]
342/// per [`validate_document`], or [`MifSchemaError::LevelFloorViolation`] if
343/// `instance` passes core schema validation but is missing fields the
344/// requested level floor requires.
345pub fn validate_level(instance: &Value, level: Level) -> Result<(), MifSchemaError> {
346    validate_document(instance)?;
347    let missing = missing_level_fields(instance, level);
348    if missing.is_empty() {
349        Ok(())
350    } else {
351        Err(MifSchemaError::LevelFloorViolation { level, missing })
352    }
353}
354
355/// Validates a standalone MIF citation object against `citation.schema.json`.
356///
357/// # Errors
358///
359/// See [`validate_document`].
360pub fn validate_citation(instance: &Value) -> Result<(), MifSchemaError> {
361    validate(citation_validator()?, instance)
362}
363
364/// Validates an ontology definition object against `ontology.schema.json`.
365///
366/// # Errors
367///
368/// See [`validate_document`].
369pub fn validate_ontology_definition(instance: &Value) -> Result<(), MifSchemaError> {
370    validate(ontology_validator()?, instance)
371}
372
373#[cfg(test)]
374mod tests {
375    use mif_problem::ToProblem;
376    use serde_json::json;
377
378    use super::{
379        Level, MifSchemaError, SchemaCompilationSource, validate_citation, validate_document,
380        validate_level, validate_ontology_definition,
381    };
382
383    fn minimal_valid_document() -> serde_json::Value {
384        json!({
385            "@context": "https://mif-spec.dev/schema/context.jsonld",
386            "@type": "Concept",
387            "@id": "urn:mif:memory:test-001",
388            "conceptType": "semantic",
389            "content": "Test content.",
390            "created": "2026-07-02T00:00:00Z",
391        })
392    }
393
394    #[test]
395    fn valid_document_passes() {
396        assert!(validate_document(&minimal_valid_document()).is_ok());
397    }
398
399    #[test]
400    fn document_missing_required_field_fails() {
401        let mut instance = minimal_valid_document();
402        instance.as_object_mut().unwrap().remove("conceptType");
403        let result = validate_document(&instance);
404        assert!(result.is_err());
405    }
406
407    #[test]
408    fn messages_reports_the_invalid_variant_and_is_empty_for_schema_compilation() {
409        let mut instance = minimal_valid_document();
410        instance.as_object_mut().unwrap().remove("conceptType");
411        let error = validate_document(&instance).unwrap_err();
412        assert!(matches!(error, MifSchemaError::Invalid(_)));
413        assert!(!error.messages().is_empty());
414
415        let compilation_error = MifSchemaError::SchemaCompilation(SchemaCompilationSource(
416            "synthetic failure for coverage".to_string(),
417        ));
418        assert!(compilation_error.messages().is_empty());
419    }
420
421    #[test]
422    fn document_with_bad_id_pattern_fails() {
423        let mut instance = minimal_valid_document();
424        instance["@id"] = json!("not-a-urn");
425        assert!(validate_document(&instance).is_err());
426    }
427
428    #[test]
429    fn document_with_entity_reference_resolves_ref_chain() {
430        let mut instance = minimal_valid_document();
431        instance["entities"] = json!([{
432            "@type": "EntityReference",
433            "entity": { "@id": "urn:mif:entity:person:jane-smith" },
434            "entityType": "Person",
435        }]);
436        assert!(validate_document(&instance).is_ok());
437    }
438
439    #[test]
440    fn document_with_invalid_entity_reference_fails() {
441        let mut instance = minimal_valid_document();
442        instance["entities"] = json!([{
443            "@type": "EntityReference",
444            "entity": { "@id": "not-a-urn" },
445        }]);
446        assert!(validate_document(&instance).is_err());
447    }
448
449    #[test]
450    fn valid_citation_passes() {
451        let citation = json!({
452            "@type": "Citation",
453            "citationType": "documentation",
454            "citationRole": "source",
455            "title": "MIF Specification",
456            "url": "https://mif-spec.dev",
457        });
458        assert!(validate_citation(&citation).is_ok());
459    }
460
461    #[test]
462    fn valid_ontology_definition_passes() {
463        let ontology = json!({
464            "ontology": {
465                "id": "mif-base",
466                "version": "1.0.0",
467            }
468        });
469        assert!(validate_ontology_definition(&ontology).is_ok());
470    }
471
472    #[test]
473    fn ontology_definition_with_bad_id_pattern_fails() {
474        let ontology = json!({
475            "ontology": {
476                "id": "Not_Valid",
477                "version": "1.0.0",
478            }
479        });
480        assert!(validate_ontology_definition(&ontology).is_err());
481    }
482
483    #[test]
484    fn entity_type_with_classification_fields_passes() {
485        // The v1.1 additive fields: aliases, exemplars, negative_examples.
486        let ontology = json!({
487            "ontology": {
488                "id": "sec-fixture",
489                "version": "1.1.0",
490            },
491            "entity_types": [{
492                "name": "control",
493                "base": "semantic",
494                "aliases": ["safeguard", "countermeasure"],
495                "exemplars": ["Enforce MFA for all administrative access"],
496                "negative_examples": ["An incident report describing a control failure"],
497            }]
498        });
499        assert!(validate_ontology_definition(&ontology).is_ok());
500    }
501
502    #[test]
503    fn entity_type_classification_fields_reject_non_string_and_empty_items() {
504        let non_string_alias = json!({
505            "ontology": { "id": "sec-fixture", "version": "1.1.0" },
506            "entity_types": [{
507                "name": "control",
508                "base": "semantic",
509                "aliases": [123],
510            }]
511        });
512        assert!(validate_ontology_definition(&non_string_alias).is_err());
513
514        let empty_exemplar = json!({
515            "ontology": { "id": "sec-fixture", "version": "1.1.0" },
516            "entity_types": [{
517                "name": "control",
518                "base": "semantic",
519                "exemplars": [""],
520            }]
521        });
522        assert!(validate_ontology_definition(&empty_exemplar).is_err());
523    }
524
525    #[test]
526    fn citation_missing_required_field_fails() {
527        let citation = json!({
528            "@type": "Citation",
529            "citationType": "documentation",
530            "citationRole": "source",
531            "title": "MIF Specification",
532        });
533        assert!(validate_citation(&citation).is_err());
534    }
535
536    #[test]
537    fn invalid_document_maps_to_versioned_problem_details() {
538        let mut instance = minimal_valid_document();
539        instance.as_object_mut().unwrap().remove("conceptType");
540        let error = validate_document(&instance).unwrap_err();
541        let problem = error.to_problem();
542
543        assert_eq!(
544            problem.problem_type,
545            "https://modeled-information-format.github.io/mif-rs/references/errors/invalid-document/v1"
546        );
547        assert_eq!(problem.status, 422);
548        assert_eq!(problem.exit_code, Some(2));
549        assert!(problem.suggested_fix.is_some());
550        assert_eq!(problem.code_actions.len(), 1);
551        assert!(problem.detail.contains("schema validation"));
552    }
553
554    #[test]
555    fn schema_compilation_error_maps_to_distinct_problem_type() {
556        let error = MifSchemaError::SchemaCompilation(SchemaCompilationSource("boom".to_string()));
557        let problem = error.to_problem();
558
559        assert_eq!(
560            problem.problem_type,
561            "https://modeled-information-format.github.io/mif-rs/references/errors/schema-compilation/v1"
562        );
563        assert_eq!(problem.status, 500);
564        assert_eq!(problem.exit_code, Some(1));
565    }
566
567    #[test]
568    fn schema_compilation_error_source_preserves_the_underlying_cause() {
569        let error = MifSchemaError::SchemaCompilation(SchemaCompilationSource("boom".to_string()));
570
571        let source = std::error::Error::source(&error).expect("source should not be None");
572        assert_eq!(source.to_string(), "boom");
573    }
574
575    fn l2_document() -> serde_json::Value {
576        let mut instance = minimal_valid_document();
577        instance["namespace"] = json!("test");
578        instance["modified"] = json!("2026-07-02T00:00:00Z");
579        instance["temporal"] = json!({});
580        instance
581    }
582
583    fn l3_document() -> serde_json::Value {
584        let mut instance = l2_document();
585        instance["provenance"] = json!({});
586        instance["temporal"]["validFrom"] = json!("2026-07-02T00:00:00Z");
587        instance
588    }
589
590    #[test]
591    fn level_display_formats_as_l_and_number() {
592        assert_eq!(Level::L1.to_string(), "L1");
593        assert_eq!(Level::L2.to_string(), "L2");
594        assert_eq!(Level::L3.to_string(), "L3");
595    }
596
597    #[test]
598    fn level_try_from_accepts_one_two_three() {
599        assert_eq!(Level::try_from(1).unwrap(), Level::L1);
600        assert_eq!(Level::try_from(2).unwrap(), Level::L2);
601        assert_eq!(Level::try_from(3).unwrap(), Level::L3);
602    }
603
604    #[test]
605    fn level_try_from_rejects_out_of_range_numbers() {
606        let error = Level::try_from(0).unwrap_err();
607        assert!(matches!(error, MifSchemaError::UnsupportedLevel(0)));
608        assert!(error.messages().is_empty());
609
610        let error = Level::try_from(4).unwrap_err();
611        assert!(matches!(error, MifSchemaError::UnsupportedLevel(4)));
612    }
613
614    #[test]
615    fn unsupported_level_error_maps_to_versioned_problem_details() {
616        let error = MifSchemaError::UnsupportedLevel(9);
617        let problem = error.to_problem();
618
619        assert_eq!(
620            problem.problem_type,
621            "https://modeled-information-format.github.io/mif-rs/references/errors/unsupported-level/v1"
622        );
623        assert_eq!(problem.status, 400);
624        assert_eq!(problem.exit_code, Some(2));
625    }
626
627    #[test]
628    fn validate_level_l1_is_satisfied_by_the_minimal_valid_document() {
629        assert!(validate_level(&minimal_valid_document(), Level::L1).is_ok());
630    }
631
632    #[test]
633    fn validate_level_l2_fails_on_a_document_missing_namespace_modified_temporal() {
634        let error = validate_level(&minimal_valid_document(), Level::L2).unwrap_err();
635        let MifSchemaError::LevelFloorViolation { level, missing } = error else {
636            unreachable!("expected LevelFloorViolation")
637        };
638        assert_eq!(level, Level::L2);
639        assert_eq!(missing, vec!["namespace", "modified", "temporal"]);
640    }
641
642    #[test]
643    fn validate_level_l2_passes_once_namespace_modified_temporal_are_present() {
644        assert!(validate_level(&l2_document(), Level::L2).is_ok());
645    }
646
647    #[test]
648    fn validate_level_l3_fails_on_a_document_missing_provenance_and_valid_from() {
649        let error = validate_level(&l2_document(), Level::L3).unwrap_err();
650        let MifSchemaError::LevelFloorViolation { level, missing } = error else {
651            unreachable!("expected LevelFloorViolation")
652        };
653        assert_eq!(level, Level::L3);
654        assert_eq!(missing, vec!["provenance", "temporal.validFrom"]);
655    }
656
657    #[test]
658    fn validate_level_l3_treats_a_null_valid_from_as_missing() {
659        let mut instance = l2_document();
660        instance["provenance"] = json!({});
661        instance["temporal"]["validFrom"] = serde_json::Value::Null;
662        let error = validate_level(&instance, Level::L3).unwrap_err();
663        let MifSchemaError::LevelFloorViolation { missing, .. } = error else {
664            unreachable!("expected LevelFloorViolation")
665        };
666        assert_eq!(missing, vec!["temporal.validFrom"]);
667    }
668
669    #[test]
670    fn validate_level_l3_passes_once_provenance_and_valid_from_are_present() {
671        assert!(validate_level(&l3_document(), Level::L3).is_ok());
672    }
673
674    #[test]
675    fn validate_level_reports_core_schema_failures_before_level_floor_checks() {
676        let mut instance = minimal_valid_document();
677        instance.as_object_mut().unwrap().remove("conceptType");
678        let error = validate_level(&instance, Level::L3).unwrap_err();
679        assert!(matches!(error, MifSchemaError::Invalid(_)));
680    }
681
682    #[test]
683    fn level_floor_violation_maps_to_versioned_problem_details() {
684        let error = validate_level(&minimal_valid_document(), Level::L2).unwrap_err();
685        let problem = error.to_problem();
686
687        assert_eq!(
688            problem.problem_type,
689            "https://modeled-information-format.github.io/mif-rs/references/errors/level-floor-violation/v1"
690        );
691        assert_eq!(problem.status, 422);
692        assert_eq!(problem.exit_code, Some(5));
693        assert!(problem.suggested_fix.is_some());
694        assert!(!error.messages().is_empty());
695    }
696}