Repository: cedar-policy/cedar
Pull request #1604 (2025-05-08 18:37:01)

Refactor entity conformance checking
This commit also fixes a bug where Cedar doesn't performance checks on tags.

## Description of changes

## Issue #, if available

## Checklist for requesting a review

The change in this PR is (choose one, and delete the other options):

- [x] A bug fix or other functionality change requiring a patch to `cedar-policy`.

I confirm that this PR (choose one, and delete the other options):

- [x] Updates the "Unreleased" section of the CHANGELOG with a description of my change (required for major/minor version bumps).

I confirm that [`cedar-spec`](https://github.com/cedar-policy/cedar-spec) (choose one, and delete the other options):

- [x] Does not require updates because my change does not impact the Cedar formal model or DRT infrastructure.

I confirm that [`docs.cedarpolicy.com`](https://docs.cedarpolicy.com/) (choose one, and delete the other options):

- [x] Does not require updates because my change does not impact the Cedar language specification.


github-actions[bot]: 
# Coverage Report
Head Commit: ```0b2aa68aa56072695571fa95e199fdd33d705b91```

Base Commit: ```5afc1fabc98f4be3b9869bd96791c65ebcf30a5b```

[Download the full coverage report.](https://github.com/cedar-policy/cedar/actions/runs/14913621117/artifacts/3088774488)

## Coverage of Added or Modified Lines of Rust Code
**Required coverage:** 80.00%

**Actual coverage:** 82.99%

**Status:** PASSED :white_check_mark:

<details>
<summary><b>Details</b></summary>

| File | Status | Covered | Coverage | Missed Lines |
|:-----|:------:|:-------:|---------:|:-------------|
cedar-policy-core/src/entities/conformance.rs | :green_circle: | 108/122 | 88.52% | 139-145, 185-191
cedar-policy-core/src/entities/conformance/err.rs | :red_circle: | 8/13 | 61.54% | 134-135, 140-141, 263
cedar-policy-core/src/entities/json/value.rs | :red_circle: | 6/12 | 50.00% | 737-742
</details>

## Coverage of All Lines of Rust Code
**Required coverage:** 80.00%

**Actual coverage:** 81.76%

**Status:** PASSED :white_check_mark:

<details>
<summary><b>Details</b></summary>

| Package | Status | Covered | Coverage | Base Coverage |
|:--------|:------:|:-------:|---------:|--------------:|
cedar-policy | :red_circle: | 3111/4528 | 68.71% | --
cedar-policy-cli | :red_circle: | 571/972 | 58.74% | --
cedar-policy-core | :green_circle: | 11966/14188 | 84.34% | --
cedar-policy-formatter | :green_circle: | 914/1043 | 87.63% | --
cedar-policy-validator | :green_circle: | 8745/10191 | 85.81% | --
cedar-wasm | :red_circle: | 0/29 | 0.00% | --
</details>


Patch:
diff --git a/cedar-policy-core/src/entities/conformance.rs b/cedar-policy-core/src/entities/conformance.rs
index e19eca81..6ac66db1 100644
--- a/cedar-policy-core/src/entities/conformance.rs
+++ b/cedar-policy-core/src/entities/conformance.rs
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, HashMap};
 
 use super::{json::err::TypeMismatchError, EntityTypeDescription, Schema, SchemaType};
 use super::{Eid, EntityUID, Literal};
@@ -47,22 +47,167 @@ impl<'a, S> EntitySchemaConformanceChecker<'a, S> {
 }
 
 impl<S: Schema> EntitySchemaConformanceChecker<'_, S> {
+    /// Validate an action
+    pub fn validate_action(&self, action: &Entity) -> Result<(), EntitySchemaConformanceError> {
+        let uid = action.uid();
+        let schema_action = self
+            .schema
+            .action(uid)
+            .ok_or_else(|| EntitySchemaConformanceError::undeclared_action(uid.clone()))?;
+        // check that the action exactly matches the schema's definition
+        if !action.deep_eq(&schema_action) {
+            return Err(EntitySchemaConformanceError::action_declaration_mismatch(
+                uid.clone(),
+            ));
+        }
+        Ok(())
+    }
+
+    /// Validate ancestors of an entity
+    pub fn validate_entity_ancestors<'a>(
+        &self,
+        uid: &EntityUID,
+        ancestors: impl Iterator<Item = &'a EntityUID>,
+        schema_etype: &impl EntityTypeDescription,
+    ) -> Result<(), EntitySchemaConformanceError> {
+        // For each ancestor that actually appears in `entity`, ensure the
+        // ancestor type is allowed by the schema
+        for ancestor_euid in ancestors {
+            validate_euid(self.schema, ancestor_euid)
+                .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
+            let ancestor_type = ancestor_euid.entity_type();
+            if schema_etype.allowed_parent_types().contains(ancestor_type) {
+                // note that `allowed_parent_types()` was transitively
+                // closed, so it's actually `allowed_ancestor_types()`
+                //
+                // thus, the check passes in this case
+            } else {
+                return Err(EntitySchemaConformanceError::invalid_ancestor_type(
+                    uid.clone(),
+                    ancestor_type.clone(),
+                ));
+            }
+        }
+        Ok(())
+    }
+
+    /// Validate attributes of an entity
+    pub fn validate_entity_attributes<'a>(
+        &self,
+        uid: &EntityUID,
+        attrs: impl Iterator<Item = (&'a SmolStr, &'a PartialValue)>,
+        schema_etype: &impl EntityTypeDescription,
+    ) -> Result<(), EntitySchemaConformanceError> {
+        let attrs: HashMap<&SmolStr, &PartialValue> = attrs.collect();
+        // Ensure that all required attributes for `etype` are actually
+        // included in `entity`
+        for required_attr in schema_etype.required_attrs() {
+            if attrs.get(&required_attr).is_none() {
+                return Err(EntitySchemaConformanceError::missing_entity_attr(
+                    uid.clone(),
+                    required_attr,
+                ));
+            }
+        }
+        // For each attribute that actually appears in `entity`, ensure it
+        // complies with the schema
+        for (attr, val) in attrs {
+            match schema_etype.attr_type(attr) {
+                None => {
+                    // `None` indicates the attribute shouldn't exist -- see
+                    // docs on the `attr_type()` trait method
+                    if !schema_etype.open_attributes() {
+                        return Err(EntitySchemaConformanceError::unexpected_entity_attr(
+                            uid.clone(),
+                            attr.clone(),
+                        ));
+                    }
+                }
+                Some(expected_ty) => {
+                    // typecheck: ensure that the entity attribute value matches
+                    // the expected type
+                    match typecheck_value_against_schematype(val, &expected_ty, self.extensions) {
+                        Ok(()) => {} // typecheck passes
+                        Err(TypecheckError::TypeMismatch(err)) => {
+                            return Err(EntitySchemaConformanceError::type_mismatch(
+                                uid.clone(),
+                                attr.clone(),
+                                err::AttrOrTag::Attr,
+                                err,
+                            ));
+                        }
+                        Err(TypecheckError::ExtensionFunctionLookup(err)) => {
+                            return Err(EntitySchemaConformanceError::extension_function_lookup(
+                                uid.clone(),
+                                attr.clone(),
+                                err::AttrOrTag::Attr,
+                                err,
+                            ));
+                        }
+                    }
+                }
+            }
+            validate_euids_in_partial_value(self.schema, val)
+                .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
+        }
+        Ok(())
+    }
+
+    /// Validate tags of an entity
+    pub fn validate_tags<'a>(
+        &self,
+        uid: &EntityUID,
+        tags: impl Iterator<Item = (&'a SmolStr, &'a PartialValue)>,
+        schema_etype: &impl EntityTypeDescription,
+    ) -> Result<(), EntitySchemaConformanceError> {
+        let tags: HashMap<&SmolStr, &PartialValue> = tags.collect();
+        match schema_etype.tag_type() {
+            None => {
+                if let Some((k, _)) = tags.iter().next() {
+                    return Err(EntitySchemaConformanceError::unexpected_entity_tag(
+                        uid.clone(),
+                        k.to_string(),
+                    ));
+                }
+            }
+            Some(expected_ty) => {
+                for (tag, val) in &tags {
+                    match typecheck_value_against_schematype(val, &expected_ty, self.extensions) {
+                        Ok(()) => {} // typecheck passes
+                        Err(TypecheckError::TypeMismatch(err)) => {
+                            return Err(EntitySchemaConformanceError::type_mismatch(
+                                uid.clone(),
+                                tag.to_string(),
+                                err::AttrOrTag::Tag,
+                                err,
+                            ));
+                        }
+                        Err(TypecheckError::ExtensionFunctionLookup(err)) => {
+                            return Err(EntitySchemaConformanceError::extension_function_lookup(
+                                uid.clone(),
+                                tag.to_string(),
+                                err::AttrOrTag::Tag,
+                                err,
+                            ));
+                        }
+                    }
+                }
+            }
+        }
+        for (_, val) in &tags {
+            validate_euids_in_partial_value(self.schema, val)
+                .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
+        }
+        Ok(())
+    }
+
     /// Validate an entity against the schema, returning an
     /// [`EntitySchemaConformanceError`] if it does not comply.
     pub fn validate_entity(&self, entity: &Entity) -> Result<(), EntitySchemaConformanceError> {
         let uid = entity.uid();
         let etype = uid.entity_type();
         if etype.is_action() {
-            let schema_action = self
-                .schema
-                .action(uid)
-                .ok_or_else(|| EntitySchemaConformanceError::undeclared_action(uid.clone()))?;
-            // check that the action exactly matches the schema's definition
-            if !entity.deep_eq(&schema_action) {
-                return Err(EntitySchemaConformanceError::action_declaration_mismatch(
-                    uid.clone(),
-                ));
-            }
+            self.validate_action(entity)?;
         } else {
             validate_euid(self.schema, uid)
                 .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
@@ -76,81 +221,10 @@ impl<S: Schema> EntitySchemaConformanceChecker<'_, S> {
                     suggested_types,
                 }
             })?;
-            // Ensure that all required attributes for `etype` are actually
-            // included in `entity`
-            for required_attr in schema_etype.required_attrs() {
-                if entity.get(&required_attr).is_none() {
-                    return Err(EntitySchemaConformanceError::missing_entity_attr(
-                        uid.clone(),
-                        required_attr,
-                    ));
-                }
-            }
-            // For each attribute that actually appears in `entity`, ensure it
-            // complies with the schema
-            for (attr, val) in entity.attrs() {
-                match schema_etype.attr_type(attr) {
-                    None => {
-                        // `None` indicates the attribute shouldn't exist -- see
-                        // docs on the `attr_type()` trait method
-                        if !schema_etype.open_attributes() {
-                            return Err(EntitySchemaConformanceError::unexpected_entity_attr(
-                                uid.clone(),
-                                attr.clone(),
-                            ));
-                        }
-                    }
-                    Some(expected_ty) => {
-                        // typecheck: ensure that the entity attribute value matches
-                        // the expected type
-                        match typecheck_value_against_schematype(val, &expected_ty, self.extensions)
-                        {
-                            Ok(()) => {} // typecheck passes
-                            Err(TypecheckError::TypeMismatch(err)) => {
-                                return Err(EntitySchemaConformanceError::type_mismatch(
-                                    uid.clone(),
-                                    attr.clone(),
-                                    err,
-                                ));
-                            }
-                            Err(TypecheckError::ExtensionFunctionLookup(err)) => {
-                                return Err(
-                                    EntitySchemaConformanceError::extension_function_lookup(
-                                        uid.clone(),
-                                        attr.clone(),
-                                        err,
-                                    ),
-                                );
-                            }
-                        }
-                    }
-                }
-                validate_euids_in_partial_value(self.schema, val)
-                    .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
-            }
-            // For each ancestor that actually appears in `entity`, ensure the
-            // ancestor type is allowed by the schema
-            for ancestor_euid in entity.ancestors() {
-                validate_euid(self.schema, ancestor_euid)
-                    .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
-                let ancestor_type = ancestor_euid.entity_type();
-                if schema_etype.allowed_parent_types().contains(ancestor_type) {
-                    // note that `allowed_parent_types()` was transitively
-                    // closed, so it's actually `allowed_ancestor_types()`
-                    //
-                    // thus, the check passes in this case
-                } else {
-                    return Err(EntitySchemaConformanceError::invalid_ancestor_type(
-                        uid.clone(),
-                        ancestor_type.clone(),
-                    ));
-                }
-            }
 
-            for (_, val) in entity.tags() {
-                validate_euids_in_partial_value(self.schema, val)
-                    .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
-            }
+            self.validate_entity_attributes(uid, entity.attrs(), &schema_etype)?;
+            self.validate_entity_ancestors(uid, entity.ancestors(), &schema_etype)?;
+            self.validate_tags(uid, entity.tags(), &schema_etype)?;
         }
         Ok(())
     }
@@ -172,10 +246,7 @@ pub fn is_valid_enumerated_entity(
 }
 
 /// Validate if `euid` is valid, provided that it's of an enumerated type
-pub(crate) fn validate_euid(
-    schema: &impl Schema,
-    euid: &EntityUID,
-) -> Result<(), InvalidEnumEntityError> {
+pub fn validate_euid(schema: &impl Schema, euid: &EntityUID) -> Result<(), InvalidEnumEntityError> {
     if let Some(desc) = schema.entity_type(euid.entity_type()) {
         if let Some(choices) = desc.enum_entity_eids() {
             is_valid_enumerated_entity(&Vec::from(choices), euid)?;
diff --git a/cedar-policy-core/src/entities/conformance/err.rs b/cedar-policy-core/src/entities/conformance/err.rs
index cea1eff5..5982e50a 100644
--- a/cedar-policy-core/src/entities/conformance/err.rs
+++ b/cedar-policy-core/src/entities/conformance/err.rs
@@ -102,12 +102,14 @@ impl EntitySchemaConformanceError {
 
     pub(crate) fn type_mismatch(
         uid: EntityUID,
-        attr: impl Into<SmolStr>,
+        attr_or_tag: impl Into<SmolStr>,
+        context: AttrOrTag,
         err: TypeMismatchError,
     ) -> Self {
         Self::TypeMismatch(TypeMismatch {
             uid,
-            attr: attr.into(),
+            attr_or_tag: attr_or_tag.into(),
+            context,
             err,
         })
     }
@@ -129,12 +131,14 @@ impl EntitySchemaConformanceError {
 
     pub(crate) fn extension_function_lookup(
         uid: EntityUID,
-        attr: impl Into<SmolStr>,
+        attr_or_tag: impl Into<SmolStr>,
+        context: AttrOrTag,
         err: ExtensionFunctionLookupError,
     ) -> Self {
         Self::ExtensionFunctionLookup(ExtensionFunctionLookup {
             uid,
-            attr: attr.into(),
+            attr_or_tag: attr_or_tag.into(),
+            context,
             err,
         })
     }
@@ -148,12 +152,14 @@ impl EntitySchemaConformanceError {
 // Don't make fields `pub`, don't make breaking changes, and use caution
 // when adding public methods.
 #[derive(Debug, Error, Diagnostic)]
-#[error("in attribute `{attr}` on `{uid}`, {err}")]
+#[error("in `{context}` `{attr_or_tag}` on `{uid}`, {err}")]
 pub struct ExtensionFunctionLookup {
     /// Entity where the error occurred
     uid: EntityUID,
     /// Name of the attribute where the error occurred
-    attr: SmolStr,
+    attr_or_tag: SmolStr,
+    /// If it shows up as an attribute or a tag value
+    context: AttrOrTag,
     /// Underlying error
     #[diagnostic(transparent)]
     err: ExtensionFunctionLookupError,
@@ -241,6 +247,24 @@ pub struct MissingRequiredEntityAttr {
     attr: SmolStr,
 }
 
+/// If the context is an attribute or a tag
+#[derive(Debug, Clone, Copy)]
+pub enum AttrOrTag {
+    /// When it's an attribute
+    Attr,
+    /// When it's a tag
+    Tag,
+}
+
+impl std::fmt::Display for AttrOrTag {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            AttrOrTag::Attr => write!(f, "attribute"),
+            AttrOrTag::Tag => write!(f, "tag"),
+        }
+    }
+}
+
 /// The given attribute on the given entity had a different type than the
 /// schema indicated
 //
@@ -248,10 +272,11 @@ pub struct MissingRequiredEntityAttr {
 // Don't make fields `pub`, don't make breaking changes, and use caution
 // when adding public methods.
 #[derive(Debug, Error, Diagnostic)]
-#[error("in attribute `{attr}` on `{uid}`, {err}")]
+#[error("in {context} `{attr_or_tag}` on `{uid}`, {err}")]
 pub struct TypeMismatch {
     uid: EntityUID,
-    attr: SmolStr,
+    attr_or_tag: SmolStr,
+    context: AttrOrTag,
     #[diagnostic(transparent)]
     err: TypeMismatchError,
 }
diff --git a/cedar-policy-core/src/entities/json/value.rs b/cedar-policy-core/src/entities/json/value.rs
index 89b29938..678f184f 100644
--- a/cedar-policy-core/src/entities/json/value.rs
+++ b/cedar-policy-core/src/entities/json/value.rs
@@ -660,7 +660,12 @@ impl<'e> ValueParser<'e> {
                     match ctx() {
                         JsonDeserializationErrorContext::EntityAttribute { uid, attr } => {
                             Err(JsonDeserializationError::EntitySchemaConformance(
-                                EntitySchemaConformanceError::type_mismatch(uid, attr, err),
+                                EntitySchemaConformanceError::type_mismatch(
+                                    uid,
+                                    attr,
+                                    crate::entities::conformance::err::AttrOrTag::Attr,
+                                    err,
+                                ),
                             ))
                         }
                         ctx => Err(JsonDeserializationError::type_mismatch(ctx, err)),
@@ -729,7 +734,12 @@ impl<'e> ValueParser<'e> {
                     match ctx() {
                         JsonDeserializationErrorContext::EntityAttribute { uid, attr } => {
                             Err(JsonDeserializationError::EntitySchemaConformance(
-                                EntitySchemaConformanceError::type_mismatch(uid, attr, err),
+                                EntitySchemaConformanceError::type_mismatch(
+                                    uid,
+                                    attr,
+                                    crate::entities::conformance::err::AttrOrTag::Attr,
+                                    err,
+                                ),
                             ))
                         }
                         ctx => Err(JsonDeserializationError::type_mismatch(ctx, err)),
diff --git a/cedar-policy-validator/src/typecheck.rs b/cedar-policy-validator/src/typecheck.rs
index d1d9d687..66384aae 100644
--- a/cedar-policy-validator/src/typecheck.rs
+++ b/cedar-policy-validator/src/typecheck.rs
@@ -143,36 +143,54 @@ impl<'a> Typechecker<'a> {
         t: &'b Template,
     ) -> Vec<(RequestEnv<'b>, PolicyCheck)> {
         self.apply_typecheck_fn_by_request_env(t, |request_env, policy_id, expr| {
-            let mut type_errors = Vec::new();
-            let single_env_typechecker = SingleEnvTypechecker {
-                schema: self.schema,
-                extensions: self.extensions,
-                mode: self.mode,
-                policy_id,
-                request_env,
-            };
-            let empty_prior_capability = CapabilitySet::new();
-            let ans = single_env_typechecker.expect_type(
-                &empty_prior_capability,
-                expr,
-                Type::primitive_boolean(),
-                &mut type_errors,
-                |_| None,
-            );
-
-            let is_false = ans.contains_type(&Type::singleton_boolean(false));
-            match (is_false, ans.typechecked(), ans.into_typed_expr()) {
-                (false, true, None) => PolicyCheck::Fail(type_errors),
-                (false, true, Some(e)) => PolicyCheck::Success(e),
-                (false, false, _) => PolicyCheck::Fail(type_errors),
-                (true, _, Some(e)) => PolicyCheck::Irrelevant(type_errors, e),
-                // PANIC SAFETY: `is_false` implies `e` has a type implies `Some(e)`.
-                #[allow(clippy::unreachable)]
-                (true, _, None) => unreachable!(),
-            }
+            self.single_env_typechecking(request_env, policy_id, expr)
         })
     }
 
+    fn single_env_typechecking<'b>(
+        &self,
+        request_env: &RequestEnv<'b>,
+        policy_id: &PolicyID,
+        expr: &Expr,
+    ) -> PolicyCheck {
+        let mut type_errors = Vec::new();
+        let single_env_typechecker = SingleEnvTypechecker {
+            schema: self.schema,
+            extensions: self.extensions,
+            mode: self.mode,
+            policy_id,
+            request_env,
+        };
+        let empty_prior_capability = CapabilitySet::new();
+        let ans = single_env_typechecker.expect_type(
+            &empty_prior_capability,
+            expr,
+            Type::primitive_boolean(),
+            &mut type_errors,
+            |_| None,
+        );
+
+        let is_false = ans.contains_type(&Type::singleton_boolean(false));
+        match (is_false, ans.typechecked(), ans.into_typed_expr()) {
+            (false, true, None) => PolicyCheck::Fail(type_errors),
+            (false, true, Some(e)) => PolicyCheck::Success(e),
+            (false, false, _) => PolicyCheck::Fail(type_errors),
+            (true, _, Some(e)) => PolicyCheck::Irrelevant(type_errors, e),
+            // PANIC SAFETY: `is_false` implies `e` has a type implies `Some(e)`.
+            #[allow(clippy::unreachable)]
+            (true, _, None) => unreachable!(),
+        }
+    }
+
+    /// Type check a `Template` by a single request environment
+    pub fn typecheck_by_single_request_env<'b>(
+        &'b self,
+        t: &'b Template,
+        request_env: &RequestEnv<'b>,
+    ) -> PolicyCheck {
+        self.single_env_typechecking(request_env, t.id(), &t.condition())
+    }
+
     /// Apply `typecheck_fn` to the given policy in every schema-defined request
     /// environment, and collect all the results.
     ///


Test patch:
diff --git a/cedar-policy/src/test/test.rs b/cedar-policy/src/test/test.rs
index 3900c846..632037c2 100644
--- a/cedar-policy/src/test/test.rs
+++ b/cedar-policy/src/test/test.rs
@@ -1403,6 +1403,7 @@ mod ancestors_tests {
 /// schema-based parsing.
 mod entity_validate_tests {
     use super::*;
+    use cedar_policy_core::entities::conformance::err::EntitySchemaConformanceError;
     use cool_asserts::assert_matches;
     use entities::err::EntitiesError;
     use serde_json::json;
@@ -2189,6 +2190,57 @@ action "g" appliesTo {
         "#;
         assert_matches!(Entity::from_json_str(entity_str, Some(&schema)), Ok(_));
     }
+
+    #[test]
+    fn from_entities_tags() {
+        let (schema, _) = Schema::from_cedarschema_str(
+            "
+            entity E tags String;
+            action a appliesTo {
+              principal: [E],
+              resource: [E],
+            };
+        ",
+        )
+        .unwrap();
+        let entity = Entity::new_with_tags(
+            r#"E::"""#.parse().unwrap(),
+            std::iter::empty(),
+            std::iter::empty(),
+            std::iter::once(("".to_owned(), RestrictedExpression::new_long(42))),
+        )
+        .unwrap();
+        assert_matches!(
+            Entities::from_entities([entity], Some(&schema)),
+            Err(EntitiesError::InvalidEntity(
+                EntitySchemaConformanceError::TypeMismatch(_)
+            ))
+        );
+
+        let (schema, _) = Schema::from_cedarschema_str(
+            "
+            entity E;
+            action a appliesTo {
+              principal: [E],
+              resource: [E],
+            };
+        ",
+        )
+        .unwrap();
+        let entity = Entity::new_with_tags(
+            r#"E::"""#.parse().unwrap(),
+            std::iter::empty(),
+            std::iter::empty(),
+            std::iter::once(("".to_owned(), RestrictedExpression::new_long(42))),
+        )
+        .unwrap();
+        assert_matches!(
+            Entities::from_entities([entity], Some(&schema)),
+            Err(EntitiesError::InvalidEntity(
+                EntitySchemaConformanceError::UnexpectedEntityTag(_)
+            ))
+        );
+    }
 }
 
 /// The main unit tests for schema-based parsing live here, as they require both