Repository: cedar-policy/cedar
Pull request #1600 (2025-05-06 20:37:38)

Add 2.5.0 schema compatibility parsing functions
## Description of changes

Tn the 3.0 and 4.0 release we made some breaking changes to the JSON schema format. One which is causing particular problems for customers is disallowing unrecognized attributes in some positions in type definitions where they previously were allowed. This PR copies the structures used to define the JSON schema format from 2.5.0 into `main` branch and uses them to implement a more compatible schema parsing mode.

Note that this does not provide full backwards compatibility. Specifically:
* `appliesTo` specifications disallow unspecified entities
* Extension type references cannot reference unknown extension types 
* Common type definition shadowing is not allowed

## Issue #, if available

## Checklist for requesting a review

The change in this PR is (choose one, and delete the other options):

- [ ] A breaking change requiring a major version bump to `cedar-policy` (e.g., changes to the signature of an existing API).
- [x] A backwards-compatible change requiring a minor version bump to `cedar-policy` (e.g., addition of a new API).
- [ ] A bug fix or other functionality change requiring a patch to `cedar-policy`.
- [ ] A change "invisible" to users (e.g., documentation, changes to "internal" crates like `cedar-policy-core`, `cedar-validator`, etc.)
- [ ] A change (breaking or otherwise) that only impacts unreleased or experimental code.

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).
- [ ] Does not update the CHANGELOG because my change does not significantly impact released code.

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.
- [ ] Requires updates, and I have made / will make these updates myself. (Please include in your description a timeline or link to the relevant PR in `cedar-spec`, and how you have tested that your updates are correct.)
- [ ] Requires updates, but I do not plan to make them in the near future. (Make sure that your changes are hidden behind a feature flag to mark them as experimental.)
- [ ] I'm not sure how my change impacts `cedar-spec`. (Post your PR anyways, and we'll discuss in the comments.)

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.
- [ ] Requires updates, and I have made / will make these updates myself. (Please include in your description a timeline or link to the relevant PR in [`cedar-docs`](https://github.com/cedar-policy/cedar-docs). PRs should be targeted at a `staging-X.Y` branch, not `main`.)
- [ ] I'm not sure how my change impacts the documentation. (Post your PR anyways, and we'll discuss in the comments.)


github-actions[bot]: 
# Coverage Report
Head Commit: ```09b8392485e47c1f182ace3f243646c20d83df89```

Base Commit: ```c3d7bfe202fc4915368b87a56fbb52fb6de9a7e0```

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

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

**Actual coverage:** 74.64%

**Status:** FAILED :x:

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

| File | Status | Covered | Coverage | Missed Lines |
|:-----|:------:|:-------:|---------:|:-------------|
cedar-policy-validator/src/deprecated_schema_compat/conversion.rs | :yellow_circle: | 142/193 | 73.58% | 42-49, 51, 55-62, 64, 68-75, 77, 85, 94-101, 104-111, 127, 187, 202, 219, 225, 295, 298
cedar-policy-validator/src/deprecated_schema_compat/json_schema.rs | :green_circle: | 14/16 | 87.50% | 42, 132
</details>

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

**Actual coverage:** 81.65%

**Status:** PASSED :white_check_mark:

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

| Package | Status | Covered | Coverage | Base Coverage |
|:--------|:------:|:-------:|---------:|--------------:|
cedar-policy | :red_circle: | 3094/4489 | 68.92% | --
cedar-policy-cli | :red_circle: | 571/972 | 58.74% | --
cedar-policy-core | :green_circle: | 11854/14069 | 84.26% | --
cedar-policy-formatter | :green_circle: | 914/1043 | 87.63% | --
cedar-policy-validator | :green_circle: | 8711/10191 | 85.48% | --
cedar-wasm | :red_circle: | 0/29 | 0.00% | --
</details>

john-h-kastner-aws: Might need to update CI scripts or make the new feature experimental. I didn't want to make it experimental since this isn't something we're experimenting with and looking to stabilize long-term, but our scripts use `--features "experimental"` rather than `--all-features` 
github-actions[bot]: 
# Coverage Report
Head Commit: ```389046767f002974d753a73e2cb64f25e681d19d```

Base Commit: ```c3d7bfe202fc4915368b87a56fbb52fb6de9a7e0```

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

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

**Actual coverage:** 74.64%

**Status:** FAILED :x:

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

| File | Status | Covered | Coverage | Missed Lines |
|:-----|:------:|:-------:|---------:|:-------------|
cedar-policy-validator/src/deprecated_schema_compat/conversion.rs | :yellow_circle: | 142/193 | 73.58% | 42-49, 51, 55-62, 64, 68-75, 77, 85, 94-101, 104-111, 127, 187, 202, 219, 225, 295, 298
cedar-policy-validator/src/deprecated_schema_compat/json_schema.rs | :green_circle: | 14/16 | 87.50% | 42, 132
</details>

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

**Actual coverage:** 81.65%

**Status:** PASSED :white_check_mark:

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

| Package | Status | Covered | Coverage | Base Coverage |
|:--------|:------:|:-------:|---------:|--------------:|
cedar-policy | :red_circle: | 3094/4489 | 68.92% | --
cedar-policy-cli | :red_circle: | 571/972 | 58.74% | --
cedar-policy-core | :green_circle: | 11854/14069 | 84.26% | --
cedar-policy-formatter | :green_circle: | 914/1043 | 87.63% | --
cedar-policy-validator | :green_circle: | 8711/10191 | 85.48% | --
cedar-wasm | :red_circle: | 0/29 | 0.00% | --
</details>

github-actions[bot]: 
# Coverage Report
Head Commit: ```609c1d6da548f4852fc8cc683089e1d278fde750```

Base Commit: ```c3d7bfe202fc4915368b87a56fbb52fb6de9a7e0```

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

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

**Actual coverage:** 74.64%

**Status:** FAILED :x:

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

| File | Status | Covered | Coverage | Missed Lines |
|:-----|:------:|:-------:|---------:|:-------------|
cedar-policy-validator/src/deprecated_schema_compat/conversion.rs | :yellow_circle: | 142/193 | 73.58% | 42-49, 51, 55-62, 64, 68-75, 77, 85, 94-101, 104-111, 127, 187, 202, 219, 225, 295, 298
cedar-policy-validator/src/deprecated_schema_compat/json_schema.rs | :green_circle: | 14/16 | 87.50% | 42, 132
</details>

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

**Actual coverage:** 81.65%

**Status:** PASSED :white_check_mark:

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

| Package | Status | Covered | Coverage | Base Coverage |
|:--------|:------:|:-------:|---------:|--------------:|
cedar-policy | :red_circle: | 3094/4489 | 68.92% | --
cedar-policy-cli | :red_circle: | 571/972 | 58.74% | --
cedar-policy-core | :green_circle: | 11854/14069 | 84.26% | --
cedar-policy-formatter | :green_circle: | 914/1043 | 87.63% | --
cedar-policy-validator | :green_circle: | 8711/10191 | 85.48% | --
cedar-wasm | :red_circle: | 0/29 | 0.00% | --
</details>


Patch:
diff --git a/cedar-policy-core/src/ast/value.rs b/cedar-policy-core/src/ast/value.rs
index 82e67d8b..9f9b802b 100644
--- a/cedar-policy-core/src/ast/value.rs
+++ b/cedar-policy-core/src/ast/value.rs
@@ -430,6 +430,50 @@ impl Set {
     pub fn iter(&self) -> impl Iterator<Item = &Value> {
         self.authoritative.iter()
     }
+
+    /// Subset test
+    pub fn is_subset(&self, other: &Set) -> bool {
+        match (&self.fast, &other.fast) {
+            // both sets are in fast form, ie, they only contain literals.
+            // Fast hashset-based implementation.
+            (Some(ls1), Some(ls2)) => ls1.is_subset(ls2.as_ref()),
+            // `self` contains non-literal(s), `other` is all-literal.
+            // The invariant about `Set::fast` should allow us to conclude that
+            // the result is `false`
+            (None, Some(_)) => false,
+            // one or both sets are in slow form, ie, contain a non-literal.
+            // Fallback to slow implementation.
+            _ => self.authoritative.is_subset(&other.authoritative),
+        }
+    }
+
+    /// Disjointness test
+    pub fn is_disjoint(&self, other: &Set) -> bool {
+        match (&self.fast, &other.fast) {
+            // both sets are in fast form, ie, they only contain literals.
+            // Fast hashset-based implementation.
+            (Some(ls1), Some(ls2)) => ls1.is_disjoint(ls2.as_ref()),
+            // one or both sets are in slow form, ie, contain a non-literal.
+            // Fallback to slow implementation.
+            _ => self.authoritative.is_disjoint(&other.authoritative),
+        }
+    }
+
+    /// Membership test
+    pub fn contains(&self, value: &Value) -> bool {
+        match (&self.fast, &value.value) {
+            // both sets are in fast form, ie, they only contain literals.
+            // Fast hashset-based implementation.
+            (Some(ls), ValueKind::Lit(lit)) => ls.contains(lit),
+            // Set is all-literal but `value` is not a literal
+            // The invariant about `Set::fast` should allow us to conclude that
+            // the result is `false`
+            (Some(_), _) => false,
+            // Set contains a non-literal
+            // Fallback to slow implementation.
+            _ => self.authoritative.contains(value),
+        }
+    }
 }
 
 impl FromIterator<Value> for Set {
diff --git a/cedar-policy-core/src/evaluator.rs b/cedar-policy-core/src/evaluator.rs
index 51621fb1..08760872 100644
--- a/cedar-policy-core/src/evaluator.rs
+++ b/cedar-policy-core/src/evaluator.rs
@@ -586,65 +586,25 @@ impl<'e> Evaluator<'e> {
                         }
                     }
                     // contains, which works on Sets
-                    BinaryOp::Contains => match arg1.value {
-                        ValueKind::Set(Set { fast: Some(h), .. }) => match arg2.try_as_lit() {
-                            Some(lit) => Ok((h.contains(lit)).into()),
-                            None => Ok(false.into()), // we know it doesn't contain a non-literal
-                        },
-                        ValueKind::Set(Set {
-                            fast: None,
-                            authoritative,
-                        }) => Ok((authoritative.contains(&arg2)).into()),
-                        _ => Err(EvaluationError::type_error_single(Type::Set, &arg1)),
-                    },
-                    // ContainsAll and ContainsAny, which work on Sets
-                    BinaryOp::ContainsAll | BinaryOp::ContainsAny => {
+                    BinaryOp::Contains => {
+                        if let Ok(s) = arg1.get_as_set() {
+                            Ok(s.contains(&arg2).into())
+                        } else {
+                            Err(EvaluationError::type_error_single(Type::Set, &arg1))
+                        }
+                    }
+                    // ContainsAll, which works on Sets
+                    BinaryOp::ContainsAll => {
                         let arg1_set = arg1.get_as_set()?;
                         let arg2_set = arg2.get_as_set()?;
-                        match (&arg1_set.fast, &arg2_set.fast) {
-                            (Some(arg1_set), Some(arg2_set)) => {
-                                // both sets are in fast form, ie, they only contain literals.
-                                // Fast hashset-based implementation.
-                                match op {
-                                    BinaryOp::ContainsAll => {
-                                        Ok((arg2_set.is_subset(arg1_set)).into())
-                                    }
-                                    BinaryOp::ContainsAny => {
-                                        Ok((!arg1_set.is_disjoint(arg2_set)).into())
-                                    }
-                                    // PANIC SAFETY `op` is checked to be one of these two above
-                                    #[allow(clippy::unreachable)]
-                                    _ => unreachable!(
-                                        "Should have already checked that op was one of these"
-                                    ),
-                                }
-                            }
-                            (_, _) => {
-                                // one or both sets are in slow form, ie, contain a non-literal.
-                                // Fallback to slow implementation.
-                                match op {
-                                    BinaryOp::ContainsAll => {
-                                        let is_subset = arg2_set
-                                            .authoritative
-                                            .iter()
-                                            .all(|item| arg1_set.authoritative.contains(item));
-                                        Ok(is_subset.into())
-                                    }
-                                    BinaryOp::ContainsAny => {
-                                        let not_disjoint = arg1_set
-                                            .authoritative
-                                            .iter()
-                                            .any(|item| arg2_set.authoritative.contains(item));
-                                        Ok(not_disjoint.into())
-                                    }
-                                    // PANIC SAFETY `op` is checked to be one of these two above
-                                    #[allow(clippy::unreachable)]
-                                    _ => unreachable!(
-                                        "Should have already checked that op was one of these"
-                                    ),
-                                }
-                            }
-                        }
+
+                        Ok((arg2_set.is_subset(arg1_set)).into())
+                    }
+                    // ContainsAny, which works on Sets
+                    BinaryOp::ContainsAny => {
+                        let arg1_set = arg1.get_as_set()?;
+                        let arg2_set = arg2.get_as_set()?;
+                        Ok((!arg1_set.is_disjoint(arg2_set)).into())
                     }
                     // GetTag and HasTag, which require an Entity on the left and a String on the right
                     BinaryOp::GetTag | BinaryOp::HasTag => {
diff --git a/cedar-policy-validator/src/deprecated_schema_compat.rs b/cedar-policy-validator/src/deprecated_schema_compat.rs
new file mode 100644
index 00000000..b9caf68b
--- /dev/null
+++ b/cedar-policy-validator/src/deprecated_schema_compat.rs
@@ -0,0 +1,21 @@
+/*
+ * Copyright Cedar Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+mod conversion;
+mod json_schema;
+
+#[cfg(test)]
+mod test;
diff --git a/cedar-policy-validator/src/deprecated_schema_compat/conversion.rs b/cedar-policy-validator/src/deprecated_schema_compat/conversion.rs
new file mode 100644
index 00000000..43b50476
--- /dev/null
+++ b/cedar-policy-validator/src/deprecated_schema_compat/conversion.rs
@@ -0,0 +1,334 @@
+/*
+ * Copyright Cedar Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+//! Defines functions for converting from the 2.5.x schema structures into the
+//! current version schema structures.
+
+use std::collections::HashMap;
+
+use crate::json_schema::{
+    self, ActionEntityUID, ActionType, ApplySpec, AttributesOrContext, CommonType, EntityType,
+    EntityTypeKind, Fragment, NamespaceDefinition, RecordType, StandardEntityType, Type,
+    TypeOfAttribute, TypeVariant,
+};
+use crate::{schema_errors::JsonDeserializationError, RawName, SchemaError};
+use crate::{ActionBehavior, ValidatorSchema};
+
+use super::json_schema as compat;
+use cedar_policy_core::extensions::Extensions;
+use cedar_policy_core::{
+    ast::{Name, UnreservedId},
+    est::Annotations,
+    FromNormalizedStr,
+};
+use itertools::Itertools;
+
+impl ValidatorSchema {
+    /// Construct a [`ValidatorSchema`] from a JSON value in the appropriate
+    /// shape.
+    pub fn from_deprecated_json_value(
+        json: serde_json::Value,
+        extensions: &Extensions<'_>,
+    ) -> Result<Self, SchemaError> {
+        Self::from_schema_frag(
+            json_schema::Fragment::<RawName>::from_deprecated_json_value(json)?,
+            ActionBehavior::default(),
+            extensions,
+        )
+    }
+
+    /// Construct a [`ValidatorSchema`] from a string containing JSON in the
+    /// appropriate shape.
+    pub fn from_deprecated_json_str(
+        json: &str,
+        extensions: &Extensions<'_>,
+    ) -> Result<Self, SchemaError> {
+        Self::from_schema_frag(
+            json_schema::Fragment::<RawName>::from_deprecated_json_str(json)?,
+            ActionBehavior::default(),
+            extensions,
+        )
+    }
+
+    /// Construct a [`ValidatorSchema`] directly from a file containing JSON
+    /// in the appropriate shape.
+    pub fn from_deprecated_json_file(
+        file: impl std::io::Read,
+        extensions: &Extensions<'_>,
+    ) -> Result<Self, SchemaError> {
+        Self::from_schema_frag(
+            json_schema::Fragment::<RawName>::from_deprecated_json_file(file)?,
+            ActionBehavior::default(),
+            extensions,
+        )
+    }
+}
+
+impl Fragment<RawName> {
+    /// Create a [`Fragment`] from a JSON value (which should be an object
+    /// of the appropriate shape).
+    pub fn from_deprecated_json_value(json: serde_json::Value) -> Result<Self, SchemaError> {
+        let compat: compat::SchemaFragment = serde_json::from_value(json).map_err(|e| {
+            SchemaError::JsonDeserialization(JsonDeserializationError::new(e, None))
+        })?;
+        compat
+            .try_into()
+            .map_err(|e| SchemaError::JsonDeserialization(JsonDeserializationError::new(e, None)))
+    }
+
+    /// Create a [`Fragment`] from a string containing JSON (which should
+    /// be an object of the appropriate shape).
+    pub fn from_deprecated_json_str(json: &str) -> Result<Self, SchemaError> {
+        let compat: compat::SchemaFragment = serde_json::from_str(json).map_err(|e| {
+            SchemaError::JsonDeserialization(JsonDeserializationError::new(e, Some(json)))
+        })?;
+        compat
+            .try_into()
+            .map_err(|e| SchemaError::JsonDeserialization(JsonDeserializationError::new(e, None)))
+    }
+
+    /// Create a [`Fragment`] directly from a file containing a JSON object.
+    pub fn from_deprecated_json_file(file: impl std::io::Read) -> Result<Self, SchemaError> {
+        let compat: compat::SchemaFragment = serde_json::from_reader(file).map_err(|e| {
+            SchemaError::JsonDeserialization(JsonDeserializationError::new(e, None))
+        })?;
+        compat
+            .try_into()
+            .map_err(|e| SchemaError::JsonDeserialization(JsonDeserializationError::new(e, None)))
+    }
+}
+
+impl TryFrom<compat::SchemaFragment> for Fragment<RawName> {
+    type Error = serde_json::Error;
+
+    fn try_from(value: compat::SchemaFragment) -> Result<Self, Self::Error> {
+        Ok(Self(
+            value
+                .0
+                .into_iter()
+                .map(|(name, nsdef)| -> Result<_, Self::Error> {
+                    let name: Option<Name> = if name.is_empty() {
+                        None
+                    } else {
+                        Some(Name::from_normalized_str(&name).map_err(|err| {
+                            serde::de::Error::custom(format!("invalid namespace `{name}`: {err}"))
+                        })?)
+                    };
+                    Ok((name, nsdef.try_into()?))
+                })
+                .try_collect()?,
+        ))
+    }
+}
+
+impl TryFrom<compat::NamespaceDefinition> for NamespaceDefinition<RawName> {
+    type Error = serde_json::Error;
+
+    fn try_from(value: compat::NamespaceDefinition) -> Result<Self, Self::Error> {
+        Ok(Self {
+            common_types: value
+                .common_types
+                .into_iter()
+                .map(|(id, ty)| -> Result<_, Self::Error> { Ok((id, ty.try_into()?)) })
+                .try_collect()?,
+            entity_types: value
+                .entity_types
+                .into_iter()
+                .map(|(id, ety)| -> Result<_, Self::Error> { Ok((id, ety.try_into()?)) })
+                .try_collect()?,
+            actions: value
+                .actions
+                .into_iter()
+                .map(|(id, aty)| aty.try_into().map(|aty| (id, aty)))
+                .try_collect()?,
+            annotations: Annotations::new(),
+            #[cfg(feature = "extended-schema")]
+            loc: None,
+        })
+    }
+}
+
+impl TryFrom<compat::SchemaType> for CommonType<RawName> {
+    type Error = serde_json::Error;
+
+    fn try_from(value: compat::SchemaType) -> Result<Self, Self::Error> {
+        Ok(Self {
+            ty: value.try_into()?,
+            annotations: Annotations::new(),
+            loc: None,
+        })
+    }
+}
+
+impl TryFrom<compat::SchemaType> for Type<RawName> {
+    type Error = serde_json::Error;
+
+    fn try_from(value: compat::SchemaType) -> Result<Self, Self::Error> {
+        Ok(match value {
+            compat::SchemaType::Type(schema_type_variant) => Self::Type {
+                ty: schema_type_variant.try_into()?,
+                loc: None,
+            },
+            compat::SchemaType::TypeDef { type_name } => Self::CommonTypeRef {
+                type_name: RawName::from_normalized_str(&type_name).map_err(|err| {
+                    serde::de::Error::custom(format!("invalid common type `{type_name}`: {err}"))
+                })?,
+                loc: None,
+            },
+        })
+    }
+}
+
+impl TryFrom<compat::SchemaTypeVariant> for TypeVariant<RawName> {
+    type Error = serde_json::Error;
+
+    fn try_from(value: compat::SchemaTypeVariant) -> Result<Self, Self::Error> {
+        Ok(match value {
+            compat::SchemaTypeVariant::String => Self::String,
+            compat::SchemaTypeVariant::Long => Self::Long,
+            compat::SchemaTypeVariant::Boolean => Self::Boolean,
+            compat::SchemaTypeVariant::Set { element } => Self::Set {
+                element: Box::new((*element).try_into()?),
+            },
+            compat::SchemaTypeVariant::Record {
+                attributes,
+                additional_attributes,
+            } => Self::Record(RecordType {
+                attributes: attributes
+                    .into_iter()
+                    .map(|(id, ty)| ty.try_into().map(|ty| (id, ty)))
+                    .try_collect()?,
+                additional_attributes,
+            }),
+            compat::SchemaTypeVariant::Entity { name } => Self::Entity {
+                name: RawName::from_normalized_str(&name)
+                    .map_err(|err| {
+                        serde::de::Error::custom(format!("invalid entity type `{name}`: {err}"))
+                    })?
+                    .into(),
+            },
+            compat::SchemaTypeVariant::Extension { name } => Self::Extension {
+                name: UnreservedId::from_normalized_str(&name).map_err(|err| {
+                    serde::de::Error::custom(format!("invalid extension type `{name}`: {err}"))
+                })?,
+            },
+        })
+    }
+}
+
+impl TryFrom<compat::EntityType> for EntityType<RawName> {
+    type Error = serde_json::Error;
+
+    fn try_from(value: compat::EntityType) -> Result<Self, Self::Error> {
+        Ok(Self {
+            kind: EntityTypeKind::Standard(StandardEntityType {
+                member_of_types: value.member_of_types,
+                shape: value.shape.try_into()?,
+                tags: None,
+            }),
+            annotations: Annotations::new(),
+            loc: None,
+        })
+    }
+}
+
+impl TryFrom<compat::ActionType> for ActionType<RawName> {
+    type Error = serde_json::Error;
+
+    fn try_from(value: compat::ActionType) -> Result<Self, Self::Error> {
+        Ok(Self {
+            // We don't support action attributes, so we don't need to actually
+            // implement conversion. If `attributes` is not `None`, then we'll
+            // include a dummy attributes map so that later conversion will
+            // correctly report an error.
+            attributes: value.attributes.map(|_attrs| HashMap::from([])),
+            applies_to: value
+                .applies_to
+                .map(|applies_to| applies_to.try_into())
+                .transpose()?,
+            member_of: value
+                .member_of
+                .map(|member_of| {
+                    member_of
+                        .into_iter()
+                        .map(|aid| aid.try_into())
+                        .try_collect()
+                })
+                .transpose()?,
+            annotations: Annotations::new(),
+            loc: None,
+            #[cfg(feature = "extended-schema")]
+            defn_loc: None,
+        })
+    }
+}
+
+impl TryFrom<compat::AttributesOrContext> for AttributesOrContext<RawName> {
+    type Error = serde_json::Error;
+
+    fn try_from(value: compat::AttributesOrContext) -> Result<Self, Self::Error> {
+        Ok(Self(value.0.try_into()?))
+    }
+}
+
+impl TryFrom<compat::ApplySpec> for ApplySpec<RawName> {
+    type Error = serde_json::Error;
+
+    fn try_from(value: compat::ApplySpec) -> Result<Self, Self::Error> {
+        // Current schema format requires that are explicitly defined when
+        // providing an `appliesTo`. We could considering converting these to
+        // empty lists to provide an even more permissive compatibility layer.
+        let Some(resource_types) = value.resource_types else {
+            return Err(serde::de::Error::missing_field("resourceTypes"));
+        };
+        let Some(principal_types) = value.principal_types else {
+            return Err(serde::de::Error::missing_field("principalTypes"));
+        };
+
+        Ok(Self {
+            resource_types,
+            principal_types,
+            context: value.context.try_into()?,
+        })
+    }
+}
+
+impl TryFrom<compat::ActionEntityUID> for ActionEntityUID<RawName> {
+    type Error = serde_json::Error;
+
+    fn try_from(value: compat::ActionEntityUID) -> Result<Self, Self::Error> {
+        Ok(Self {
+            id: value.id,
+            ty: value.ty,
+            #[cfg(feature = "extended-schema")]
+            loc: None,
+        })
+    }
+}
+
+impl TryFrom<compat::TypeOfAttribute> for TypeOfAttribute<RawName> {
+    type Error = serde_json::Error;
+
+    fn try_from(value: compat::TypeOfAttribute) -> Result<Self, Self::Error> {
+        Ok(Self {
+            ty: value.ty.try_into()?,
+            annotations: Annotations::new(),
+            required: value.required,
+            #[cfg(feature = "extended-schema")]
+            loc: None,
+        })
+    }
+}
diff --git a/cedar-policy-validator/src/deprecated_schema_compat/json_schema.rs b/cedar-policy-validator/src/deprecated_schema_compat/json_schema.rs
new file mode 100644
index 00000000..28fc76da
--- /dev/null
+++ b/cedar-policy-validator/src/deprecated_schema_compat/json_schema.rs
@@ -0,0 +1,176 @@
+/*
+ * Copyright Cedar Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+//! Contains a copy of the schema structures used to define JSON schema parsing
+//! in version 2.5.x.
+//!
+//! Do not make changes to this file without careful consideration. It exist to
+//! provide compatibility with version 2.5.x, so changes should not result in
+//! any new errors being reported. Specifically, it provides a parsing mode that
+//! ignores (some) unrecognized fields, so we not should change it to report these
+//! as errors.  We also do not need to update these definitions to support new
+//! features.
+
+use cedar_policy_core::ast::UnreservedId;
+use serde::{Deserialize, Serialize};
+use serde_with::serde_as;
+use smol_str::SmolStr;
+use std::collections::{BTreeMap, HashMap};
+
+use crate::{json_schema::CommonTypeId, RawName};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct SchemaFragment(
+    #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")]
+    pub  HashMap<SmolStr, NamespaceDefinition>,
+);
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde_as]
+#[serde(deny_unknown_fields)]
+pub struct NamespaceDefinition {
+    // Key changed from `SmolStr` in 2.5.0 to `CommonTypeId` to avoid excess code duplication
+    #[serde(default)]
+    #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")]
+    #[serde(rename = "commonTypes")]
+    pub common_types: HashMap<CommonTypeId, SchemaType>,
+    // Key changed from `SmolStr` in 2.5.0 to `UnreservedId` to avoid excess code duplication
+    #[serde(rename = "entityTypes")]
+    #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")]
+    pub entity_types: HashMap<UnreservedId, EntityType>,
+    #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")]
+    pub actions: HashMap<SmolStr, ActionType>,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct EntityType {
+    // Key changed from `SmolStr` in 2.5.0 to `RawName` to avoid excess code duplication
+    #[serde(default)]
+    #[serde(rename = "memberOfTypes")]
+    pub member_of_types: Vec<RawName>,
+    #[serde(default)]
+    pub shape: AttributesOrContext,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct AttributesOrContext(pub SchemaType);
+
+impl Default for AttributesOrContext {
+    fn default() -> Self {
+        Self(SchemaType::Type(SchemaTypeVariant::Record {
+            attributes: BTreeMap::new(),
+            additional_attributes: false,
+        }))
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ActionType {
+    #[serde(default)]
+    pub attributes: Option<HashMap<SmolStr, serde_json::Value>>,
+    #[serde(default)]
+    #[serde(rename = "appliesTo")]
+    pub applies_to: Option<ApplySpec>,
+    #[serde(default)]
+    #[serde(rename = "memberOf")]
+    pub member_of: Option<Vec<ActionEntityUID>>,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ApplySpec {
+    // Key changed from `SmolStr` in 2.5.0 to `RawName` to avoid excess code duplication
+    #[serde(default)]
+    #[serde(rename = "resourceTypes")]
+    pub resource_types: Option<Vec<RawName>>,
+    // Key changed from `SmolStr` in 2.5.0 to `RawName` to avoid excess code duplication
+    #[serde(default)]
+    #[serde(rename = "principalTypes")]
+    pub principal_types: Option<Vec<RawName>>,
+    #[serde(default)]
+    pub context: AttributesOrContext,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ActionEntityUID {
+    pub id: SmolStr,
+
+    // Key changed from `SmolStr` in 2.5.0 to `RawName` to avoid excess code duplication
+    #[serde(rename = "type")]
+    #[serde(default)]
+    pub ty: Option<RawName>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum SchemaType {
+    Type(SchemaTypeVariant),
+    TypeDef {
+        #[serde(rename = "type")]
+        type_name: SmolStr,
+    },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(tag = "type")]
+// This annotation exists in 2.5.x and prior 2.x versions of the JSON schema
+// structs but doesn't actually deny (all) unknown fields here. Supporting
+// unknown fields is the primary motivation of this module, so leaving this
+// feels a bit odd, but we specifically want to support the same 2.5.x behavior
+// around unknown fields, so we should leave this as is unless we have a reason not to.
+#[serde(deny_unknown_fields)]
+pub enum SchemaTypeVariant {
+    String,
+    Long,
+    Boolean,
+    Set {
+        element: Box<SchemaType>,
+    },
+    Record {
+        #[serde(with = "serde_with::rust::maps_duplicate_key_is_error")]
+        attributes: BTreeMap<SmolStr, TypeOfAttribute>,
+        #[serde(rename = "additionalAttributes")]
+        #[serde(default = "additional_attributes_default")]
+        additional_attributes: bool,
+    },
+    Entity {
+        name: SmolStr,
+    },
+    Extension {
+        name: SmolStr,
+    },
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Eq, PartialOrd, Ord)]
+pub struct TypeOfAttribute {
+    #[serde(flatten)]
+    pub ty: SchemaType,
+    #[serde(default = "record_attribute_required_default")]
+    pub required: bool,
+}
+
+fn additional_attributes_default() -> bool {
+    false
+}
+
+fn record_attribute_required_default() -> bool {
+    true
+}
diff --git a/cedar-policy-validator/src/json_schema.rs b/cedar-policy-validator/src/json_schema.rs
index aae88228..b056a6d5 100644
--- a/cedar-policy-validator/src/json_schema.rs
+++ b/cedar-policy-validator/src/json_schema.rs
@@ -989,7 +989,7 @@ pub struct ActionEntityUID<N> {
     #[serde(rename = "type")]
     #[serde(default)]
     #[serde(skip_serializing_if = "Option::is_none")]
-    ty: Option<N>,
+    pub ty: Option<N>,
     #[cfg(feature = "extended-schema")]
     #[serde(skip)]
     /// Source location - if available
diff --git a/cedar-policy-validator/src/lib.rs b/cedar-policy-validator/src/lib.rs
index 4e675871..182cd15d 100644
--- a/cedar-policy-validator/src/lib.rs
+++ b/cedar-policy-validator/src/lib.rs
@@ -45,6 +45,7 @@ mod rbac;
 mod schema;
 pub use schema::err::*;
 pub use schema::*;
+mod deprecated_schema_compat;
 pub mod json_schema;
 mod str_checks;
 pub use str_checks::confusable_string_checks;
diff --git a/cedar-policy/CHANGELOG.md b/cedar-policy/CHANGELOG.md
index f374afe4..0a16c429 100644
--- a/cedar-policy/CHANGELOG.md
+++ b/cedar-policy/CHANGELOG.md
@@ -16,6 +16,10 @@ Cedar Language Version: TBD
 ### Added
 
 - Added `Entities::upsert_entities()` to add or update `Entity`s in an `Entities` struct (resolving #1479)
+- Added schema parsing functions to improve compatibility with JSON schema originally writing for versions 2.5.0
+  and earlier. These functions will ignore unrecognized keys in some positions where they are currently an error,
+  matching the behavior of earlier versions.  This is intended help some users migrate to the current 4.0 schema
+  format. The new functions are deprecated and placed behind the `deprecated-schema-compat` feature. (#1600)
 
 ### Changed
 
diff --git a/cedar-policy/Cargo.toml b/cedar-policy/Cargo.toml
index a37a9aa2..b694658d 100644
--- a/cedar-policy/Cargo.toml
+++ b/cedar-policy/Cargo.toml
@@ -52,7 +52,7 @@ corpus-timing = []
 
 # Experimental features.
 # Enable all experimental features with `cargo build --features "experimental"`
-experimental = ["partial-eval", "permissive-validate", "partial-validate", "entity-manifest", "protobufs", "tolerant-ast", "extended-schema"]
+experimental = ["partial-eval", "permissive-validate", "partial-validate", "entity-manifest", "protobufs", "tolerant-ast", "extended-schema", "deprecated-schema-compat"]
 entity-manifest = ["cedar-policy-validator/entity-manifest"]
 partial-eval = ["cedar-policy-core/partial-eval", "cedar-policy-validator/partial-eval"]
 permissive-validate = []
@@ -61,6 +61,10 @@ protobufs = ["dep:prost", "dep:prost-build"]
 wasm = ["serde-wasm-bindgen", "tsify", "wasm-bindgen"]
 tolerant-ast = ["cedar-policy-core/tolerant-ast",  "cedar-policy-validator/tolerant-ast", "cedar-policy-formatter/tolerant-ast"]
 extended-schema = ["cedar-policy-validator/extended-schema"]
+# Not exactly experimental but rather a feature for supporting deprecated
+# parsing behavior. API is stable, but hopefully will be removed in a future
+# release.
+deprecated-schema-compat = []
 
 [lib]
 # cdylib required for wasm
diff --git a/cedar-policy/src/api.rs b/cedar-policy/src/api.rs
index 53c76770..644fe002 100644
--- a/cedar-policy/src/api.rs
+++ b/cedar-policy/src/api.rs
@@ -34,6 +34,9 @@ use cedar_policy_validator::json_schema;
 use cedar_policy_validator::typecheck::{PolicyCheck, Typechecker};
 pub use id::*;
 
+#[cfg(feature = "deprecated-schema-compat")]
+mod deprecated_schema_compat;
+
 mod err;
 pub use err::*;
 
diff --git a/cedar-policy/src/api/deprecated_schema_compat.rs b/cedar-policy/src/api/deprecated_schema_compat.rs
new file mode 100644
index 00000000..ef9896b0
--- /dev/null
+++ b/cedar-policy/src/api/deprecated_schema_compat.rs
@@ -0,0 +1,1121 @@
+/*
+ * Copyright Cedar Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+//! Defines functions for parsing schema using a format (mostly) compatible with
+//! version 2.5.0 of this library.
+
+// Note: We'll likely want to remove these functions in the future if we can get
+// everyone on to the updated schema format. If we ever get there, we should
+// keep some of the tests in this file. Many of the tests check error behavior
+// and actually test the behavior of standard schema parsing in addition to the
+// functions defined.
+
+use cedar_policy_core::extensions::Extensions;
+use cedar_policy_validator::SchemaError;
+
+use super::{Schema, SchemaFragment};
+
+impl SchemaFragment {
+    /// Create a [`SchemaFragment`] from a JSON value in shape of the deprecated
+    /// JSON schema format used for versions 2.5.0 of this library.  Most
+    /// callers should use [`SchemaFragment::from_json_value`] instead.
+    #[deprecated(
+        since = "4.5.0",
+        note = "use `SchemaFragment::from_json_value` instead"
+    )]
+    pub fn from_deprecated_json_value(json: serde_json::Value) -> Result<Self, SchemaError> {
+        let lossless =
+            cedar_policy_validator::json_schema::Fragment::from_deprecated_json_value(json)?;
+        Ok(Self {
+            value: lossless.clone().try_into()?,
+            lossless,
+        })
+    }
+
+    /// Create a [`SchemaFragment`] from a string containing JSON in the
+    /// deprecated JSON schema format used for versions 2.5.0 of this library.
+    /// Most callers should use [`SchemaFragment::from_json_str`] instead.
+    #[deprecated(since = "4.5.0", note = "use `SchemaFragment::from_json_str` instead")]
+    pub fn from_deprecated_json_str(src: &str) -> Result<Self, SchemaError> {
+        let lossless =
+            cedar_policy_validator::json_schema::Fragment::from_deprecated_json_str(src)?;
+        Ok(Self {
+            value: lossless.clone().try_into()?,
+            lossless,
+        })
+    }
+
+    /// Create a [`SchemaFragment`] directly from a JSON file containing JSON in
+    /// the deprecated JSON schema format used for versions 2.5.0 of this
+    /// library. Most callers should use [`SchemaFragment::from_json_file`]
+    /// instead.
+    #[deprecated(since = "4.5.0", note = "use `SchemaFragment::from_json_file` instead")]
+    pub fn from_deprecated_json_file(file: impl std::io::Read) -> Result<Self, SchemaError> {
+        let lossless =
+            cedar_policy_validator::json_schema::Fragment::from_deprecated_json_file(file)?;
+        Ok(Self {
+            value: lossless.clone().try_into()?,
+            lossless,
+        })
+    }
+}
+
+impl Schema {
+    /// Create a [`Schema`] from a string containing JSON in the
+    /// deprecated JSON schema format used for versions 2.5.0 of this library.
+    /// Most callers should use [`Schema::from_json_str`] instead.
+    #[deprecated(since = "4.5.0", note = "use `Schema::from_json_str` instead")]
+    pub fn from_deprecated_json_value(json: serde_json::Value) -> Result<Self, SchemaError> {
+        Ok(Self(
+            cedar_policy_validator::ValidatorSchema::from_deprecated_json_value(
+                json,
+                Extensions::all_available(),
+            )?,
+        ))
+    }
+
+    /// Create a [`Schema`] from a JSON value in shape of the deprecated
+    /// JSON schema format used for versions 2.5.0 of this library.  Most
+    /// callers should use [`Schema::from_json_value`] instead.
+    #[deprecated(since = "4.5.0", note = "use `Schema::from_json_value` instead")]
+    pub fn from_deprecated_json_str(json: &str) -> Result<Self, SchemaError> {
+        Ok(Self(
+            cedar_policy_validator::ValidatorSchema::from_deprecated_json_str(
+                json,
+                Extensions::all_available(),
+            )?,
+        ))
+    }
+
+    /// Create a [`Schema`] directly from a JSON file containing JSON in
+    /// the deprecated JSON schema format used for versions 2.5.0 of this
+    /// library. Most callers should use [`Schema::from_json_file`] instead.
+    #[deprecated(since = "4.5.0", note = "use `Schema::from_json_file` instead")]
+    pub fn from_deprecated_json_file(file: impl std::io::Read) -> Result<Self, SchemaError> {
+        Ok(Self(
+            cedar_policy_validator::ValidatorSchema::from_deprecated_json_file(
+                file,
+                Extensions::all_available(),
+            )?,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod test_utils {
+    use cedar_policy_core::test_utils::{
+        expect_err, ExpectedErrorMessage, ExpectedErrorMessageBuilder,
+    };
+    use miette::Report;
+    use serde_json::json;
+
+    use crate::Schema;
+
+    fn schema_with_entity_attribute(attr_ty: serde_json::Value) -> serde_json::Value {
+        json!({
+            "ns": {
+                "commonTypes": {"ty": {"type": "Long"}},
+                "entityTypes": {
+                    "User": {
+                        "shape": {
+                            "type": "Record",
+                            "attributes": {
+                                "foo": attr_ty,
+                            },
+                        },
+                    }
+                },
+                "actions": {},
+            }
+        })
+    }
+
+    fn schema_with_context_attribute(attr_ty: serde_json::Value) -> serde_json::Value {
+        json!({
+            "ns": {
+                "commonTypes": {"ty": {"type": "Long"}},
+                "entityTypes": {
+                    "User": {}
+                },
+                "actions": {
+                    "Act": {
+                        "appliesTo": {
+                            "principalTypes": [],
+                            "resourceTypes": [],
+                            "context": {
+                                "type": "Record",
+                                "attributes": {
+                                    "foo": attr_ty,
+                                },
+                            }
+                        }
+                    }
+                },
+            }
+        })
+    }
+
+    fn schema_with_common_type(ty: serde_json::Value) -> serde_json::Value {
+        json!({
+            "ns": {
+                "commonTypes": {
+                    "ty": {"type": "Long"},
+                    "ty2": ty,
+                },
+                "entityTypes": {
+                    "User": {}
+                },
+                "actions": {},
+            }
+        })
+    }
+
+    #[track_caller]
+    #[allow(deprecated)]
+    pub(crate) fn assert_type_json_ok_deprecated_and_err_standard(
+        ty: serde_json::Value,
+        err: &str,
+    ) {
+        let in_entity_attr = schema_with_entity_attribute(ty.clone());
+        Schema::from_deprecated_json_value(in_entity_attr.clone()).unwrap();
+        expect_err(
+            "",
+            &Report::new(Schema::from_json_value(in_entity_attr).unwrap_err()),
+            &ExpectedErrorMessageBuilder::error(err).build(),
+        );
+        let in_context = schema_with_context_attribute(ty.clone());
+        Schema::from_deprecated_json_value(in_context.clone()).unwrap();
+        expect_err(
+            "",
+            &Report::new(Schema::from_json_value(in_context).unwrap_err()),
+            &ExpectedErrorMessageBuilder::error(err).build(),
+        );
+        let in_common = schema_with_common_type(ty.clone());
+        Schema::from_deprecated_json_value(in_common.clone()).unwrap();
+        expect_err(
+            "",
+            &Report::new(Schema::from_json_value(in_common).unwrap_err()),
+            &ExpectedErrorMessageBuilder::error(err).build(),
+        );
+    }
+
+    #[track_caller]
+    #[allow(deprecated)]
+    pub(crate) fn assert_type_json_ok_deprecated_and_standard(ty: serde_json::Value) {
+        let in_entity_attr = schema_with_entity_attribute(ty.clone());
+        Schema::from_deprecated_json_value(in_entity_attr.clone()).unwrap();
+        Schema::from_json_value(in_entity_attr.clone()).unwrap();
+        let in_context = schema_with_context_attribute(ty.clone());
+        Schema::from_deprecated_json_value(in_context.clone()).unwrap();
+        Schema::from_json_value(in_context.clone()).unwrap();
+        let in_common = schema_with_common_type(ty.clone());
+        Schema::from_deprecated_json_value(in_common.clone()).unwrap();
+        Schema::from_json_value(in_common.clone()).unwrap();
+    }
+
+    #[track_caller]
+    #[allow(deprecated)]
+    pub(crate) fn assert_type_json_err_deprecated_and_standard(
+        ty: serde_json::Value,
+        current_err: &ExpectedErrorMessage<'_>,
+        deprecated_err: &ExpectedErrorMessage<'_>,
+    ) {
+        let in_entity_attr = schema_with_entity_attribute(ty.clone());
+        assert_schema_json_err_deprecated_and_standard(in_entity_attr, current_err, deprecated_err);
+        let in_context = schema_with_context_attribute(ty.clone());
+        assert_schema_json_err_deprecated_and_standard(in_context, current_err, deprecated_err);
+        let in_common = schema_with_common_type(ty.clone());
+        assert_schema_json_err_deprecated_and_standard(in_common, current_err, deprecated_err);
+    }
+
+    #[track_caller]
+    #[allow(deprecated)]
+    pub(crate) fn assert_schema_json_err_deprecated_and_standard(
+        schema: serde_json::Value,
+        current_err: &ExpectedErrorMessage<'_>,
+        deprecated_err: &ExpectedErrorMessage<'_>,
+    ) {
+        expect_err(
+            "",
+            &Report::new(Schema::from_json_value(schema.clone()).unwrap_err()),
+            current_err,
+        );
+        expect_err(
+            "",
+            &Report::new(Schema::from_deprecated_json_value(schema.clone()).unwrap_err()),
+            deprecated_err,
+        );
+    }
+}
+
+/// These tests assert that unknown fields are allowed in specific locations in
+/// the compatibility mode, but not allowed in the standard schema parsing mode.
+#[cfg(test)]
+mod extra_fields_allowed {
+    use super::test_utils::*;
+    use serde_json::json;
+
+    #[test]
+    fn in_long() {
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Long",
+                "bogus": "bogus",
+            }),
+            "unknown field `bogus`, expected one of `type`, `element`, `attributes`, `additionalAttributes`, `name`",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Long",
+                "name": "my_long",
+            }),
+            "unknown field `name`, there are no fields",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Long",
+                "element": "bogus"
+            }),
+            "invalid type: string \"bogus\", expected builtin type or reference to type defined in commonTypes",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Long",
+                "attributes": "bogus",
+            }),
+            "invalid type: string \"bogus\", expected a map",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Long",
+                "additionalAttributes": "bogus",
+            }),
+            "invalid type: string \"bogus\", expected a boolean",
+        );
+        assert_type_json_ok_deprecated_and_standard(json!({
+            "type": "Long",
+            "annotations": {},
+        }));
+    }
+
+    #[test]
+    fn in_bool() {
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Boolean",
+                "bogus": "bogus",
+            }),
+            "unknown field `bogus`, expected one of `type`, `element`, `attributes`, `additionalAttributes`, `name`",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Boolean",
+                "name": "my_long",
+            }),
+            "unknown field `name`, there are no fields",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Boolean",
+                "element": "bogus",
+            }),
+            "invalid type: string \"bogus\", expected builtin type or reference to type defined in commonTypes",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Boolean",
+                "attributes": "bogus",
+            }),
+            "invalid type: string \"bogus\", expected a map",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Boolean",
+                "additionalAttributes": "bogus",
+            }),
+            "invalid type: string \"bogus\", expected a boolean",
+        );
+        assert_type_json_ok_deprecated_and_standard(json!({
+            "type": "Boolean",
+            "annotations": {},
+        }));
+    }
+
+    #[test]
+    fn in_string() {
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "String",
+                "bogus": "bogus",
+            }),
+            "unknown field `bogus`, expected one of `type`, `element`, `attributes`, `additionalAttributes`, `name`",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "String",
+                "name": "bogus",
+            }),
+            "unknown field `name`, there are no fields",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "String",
+                "element": {"type": "Long"}
+            }),
+            "unknown field `element`, there are no fields",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "String",
+                "attributes": {},
+            }),
+            "unknown field `attributes`, there are no fields",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "String",
+                "additionalAttributes": false,
+            }),
+            "unknown field `additionalAttributes`, there are no fields",
+        );
+        assert_type_json_ok_deprecated_and_standard(json!({
+            "type": "String",
+            "annotations": {},
+        }));
+    }
+
+    #[test]
+    fn in_common() {
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "ty",
+                "bogus": "bogus",
+            }),
+            "unknown field `bogus`, expected one of `type`, `element`, `attributes`, `additionalAttributes`, `name`",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "ty",
+                "name": "my_long",
+            }),
+            "unknown field `name`, there are no fields",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "ty",
+                "element": 10,
+            }),
+            "invalid type: integer `10`, expected builtin type or reference to type defined in commonTypes",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "ty",
+                "attributes": ["bogus"],
+            }),
+            "invalid type: sequence, expected a map",
+        );
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "ty",
+                "additionalAttributes": {"bogus": "bogus"},
+            }),
+            "invalid type: map, expected a boolean",
+        );
+        assert_type_json_ok_deprecated_and_standard(json!({
+            "type": "ty",
+            "annotations": {},
+        }));
+    }
+
+    #[test]
+    fn in_set_elem() {
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Set",
+                "element": {"type": "Long", "name": "my_long"},
+            }),
+            "unknown field `name`, there are no fields",
+        );
+    }
+
+    #[test]
+    fn in_record_attr() {
+        assert_type_json_ok_deprecated_and_err_standard(
+            json!({
+                "type": "Record",
+                "attributes": { "a": {"type": "Long", "name": "foo"} },
+            }),
+            "unknown field `name`, there are no fields",
+        );
+    }
+}
+
+/// These tests check the behavior of extra fields that should still be forbidden
+#[cfg(test)]
+mod extra_fields_forbidden {
+    use super::test_utils::*;
+    use cedar_policy_core::test_utils::ExpectedErrorMessageBuilder;
+    use serde_json::json;
+
+    #[test]
+    fn in_set() {
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Set",
+                "element": {"type": "Long"},
+                "bogus": "bogus",
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `bogus`, expected one of `type`, `element`, `attributes`, `additionalAttributes`, `name`")
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Set")
+                .help("neither `ns::Set` nor `Set` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Set")
+                .build(),
+        );
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Set",
+                "element": {"type": "Long"},
+                "name": "my_long",
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `name`, expected `element`")
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Set")
+                .help("neither `ns::Set` nor `Set` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Set")
+                .build(),
+        );
+
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Set",
+                "element": {"type": "Long"},
+                "attributes": {},
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `attributes`, expected `element`")
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Set")
+                .help("neither `ns::Set` nor `Set` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Set")
+                .build(),
+        );
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Set",
+                "element": {"type": "Long"},
+                "additionalAttributes": false,
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `additionalAttributes`, expected `element`")
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Set")
+                .help("neither `ns::Set` nor `Set` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Set")
+                .build(),
+        );
+    }
+
+    #[test]
+    fn in_entity() {
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Entity",
+                "name": "User",
+                "bogus": "bogus",
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `bogus`, expected one of `type`, `element`, `attributes`, `additionalAttributes`, `name`")
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Entity")
+                .help("neither `ns::Entity` nor `Entity` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Entity")
+                .build(),
+        );
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Entity",
+                "name": "User",
+                "element": {"type": "Long"},
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `element`, expected `name`")
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Entity")
+                .help("neither `ns::Entity` nor `Entity` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Entity")
+                .build(),
+        );
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Entity",
+                "name": "User",
+                "attributes": {},
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `attributes`, expected `name`")
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Entity")
+                .help("neither `ns::Entity` nor `Entity` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Entity")
+                .build(),
+        );
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Entity",
+                "name": "User",
+                "additionalAttributes": false,
+            }),
+            &ExpectedErrorMessageBuilder::error( "unknown field `additionalAttributes`, expected `name`",)
+                .build(),
+            &ExpectedErrorMessageBuilder::error( "failed to resolve type: Entity",)
+                .help("neither `ns::Entity` nor `Entity` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Entity")
+                .build(),
+        );
+    }
+
+    #[test]
+    fn in_extension() {
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Extension",
+                "name": "ip",
+                "bogus": "bogus"
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `bogus`, expected one of `type`, `element`, `attributes`, `additionalAttributes`, `name`")
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Extension")
+                .help("neither `ns::Extension` nor `Extension` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Extension")
+                .build(),
+        );
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Extension",
+                "name": "ip",
+                "element": {"type": "Long"},
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `element`, expected `name`")
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Extension")
+                .help("neither `ns::Extension` nor `Extension` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Extension")
+                .build(),
+        );
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Extension",
+                "name": "ip",
+                "attributes": {},
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `attributes`, expected `name`")
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Extension")
+                .help("neither `ns::Extension` nor `Extension` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Extension")
+                .build(),
+        );
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Extension",
+                "name": "ip",
+                "additionalAttributes": false,
+            }),
+            &ExpectedErrorMessageBuilder::error( "unknown field `additionalAttributes`, expected `name`",)
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Extension")
+                .help("neither `ns::Extension` nor `Extension` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Extension")
+                .build(),
+        );
+    }
+
+    #[test]
+    fn in_record() {
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Record",
+                "attributes": {},
+                "bogus": "bogus"
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `bogus`, expected one of `type`, `element`, `attributes`, `additionalAttributes`, `name`")
+                .build(),
+            &ExpectedErrorMessageBuilder::error("failed to resolve type: Record")
+                .help("neither `ns::Record` nor `Record` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Record")
+                .build(),
+        );
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Record",
+                "attributes": {},
+                "element": {"type": "Long"},
+            }),
+            &ExpectedErrorMessageBuilder::error( "unknown field `element`, expected `attributes` or `additionalAttributes`",)
+                .build(),
+            &ExpectedErrorMessageBuilder::error( "failed to resolve type: Record",)
+                .help("neither `ns::Record` nor `Record` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Record")
+                .build(),
+        );
+        assert_type_json_err_deprecated_and_standard(
+            json!({
+                "type": "Record",
+                "attributes": {},
+                "name": "ip",
+            }),
+            &ExpectedErrorMessageBuilder::error( "unknown field `name`, expected `attributes` or `additionalAttributes`",)
+                 .build(),
+            &ExpectedErrorMessageBuilder::error( "failed to resolve type: Record",)
+                .help("neither `ns::Record` nor `Record` refers to anything that has been declared as a common type")
+                .exactly_one_underline("Record")
+                .build(),
+        );
+    }
+
+    #[test]
+    fn in_namespace() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": {},
+                    "actions": {},
+                    "commonTypes": {},
+                    "foo": {},
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `foo`, expected one of `commonTypes`, `entityTypes`, `actions`, `annotations`").build(),
+            &ExpectedErrorMessageBuilder::error("unknown field `foo`, expected one of `commonTypes`, `entityTypes`, `actions`").build(),
+        );
+    }
+
+    #[test]
+    fn in_entity_type() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": {
+                        "User": {
+                            "foo": {},
+                        }
+                    },
+                    "actions": {},
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `foo`, expected one of `memberOfTypes`, `shape`, `tags`, `enum`, `annotations`").build(),
+            &ExpectedErrorMessageBuilder::error("unknown field `foo`, expected `memberOfTypes` or `shape`").build(),
+        );
+    }
+
+    #[test]
+    fn in_action() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": {
+                    },
+                    "actions": {
+                        "act": {
+                            "foo": {}
+                        },
+                    },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("unknown field `foo`, expected one of `attributes`, `appliesTo`, `memberOf`, `annotations`").build(),
+            &ExpectedErrorMessageBuilder::error("unknown field `foo`, expected one of `attributes`, `appliesTo`, `memberOf`").build(),
+        );
+    }
+
+    #[test]
+    fn in_applies_to() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": { },
+                    "actions": {
+                        "act": {
+                            "appliesTo": {
+                                "principalTypes": [],
+                                "resourceTypes": [],
+                                "foo": {},
+                            }
+                        },
+                    },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error(
+                "unknown field `foo`, expected one of `resourceTypes`, `principalTypes`, `context`",
+            )
+            .build(),
+            &ExpectedErrorMessageBuilder::error(
+                "unknown field `foo`, expected one of `resourceTypes`, `principalTypes`, `context`",
+            )
+            .build(),
+        );
+    }
+}
+
+/// Unspecified `appliesTo` is still reported as an error
+#[cfg(test)]
+mod unspecified_not_allowed {
+    use cedar_policy_core::test_utils::ExpectedErrorMessageBuilder;
+    use serde_json::json;
+
+    use super::test_utils::*;
+
+    #[test]
+    fn missing_principal() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": { },
+                    "actions": {
+                        "act": {
+                            "appliesTo": {
+                                "resourceTypes": [],
+                            }
+                        },
+                    },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("missing field `principalTypes`").build(),
+            &ExpectedErrorMessageBuilder::error("missing field `principalTypes`").build(),
+        );
+    }
+
+    #[test]
+    fn missing_resource() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": { },
+                    "actions": {
+                        "act": {
+                            "appliesTo": {
+                                "principalTypes": [],
+                            }
+                        },
+                    },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("missing field `resourceTypes`").build(),
+            &ExpectedErrorMessageBuilder::error("missing field `resourceTypes`").build(),
+        );
+    }
+
+    #[test]
+    fn missing_both() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": { },
+                    "actions": {
+                        "act": {
+                            "appliesTo": { }
+                        },
+                    },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("missing field `resourceTypes`").build(),
+            &ExpectedErrorMessageBuilder::error("missing field `resourceTypes`").build(),
+        );
+    }
+
+    #[test]
+    fn null_values() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": { },
+                    "actions": {
+                        "act": {
+                            "appliesTo": {
+                                "principalTypes": [],
+                                "resourceTypes": null
+                            }
+                        },
+                    },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid type: null, expected a sequence").build(),
+            &ExpectedErrorMessageBuilder::error("missing field `resourceTypes`").build(),
+        );
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": { },
+                    "actions": {
+                        "act": {
+                            "appliesTo": {
+                                "principalTypes": null,
+                                "resourceTypes": [],
+                            }
+                        },
+                    },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid type: null, expected a sequence").build(),
+            &ExpectedErrorMessageBuilder::error("missing field `principalTypes`").build(),
+        );
+    }
+}
+
+#[cfg(test)]
+mod invalid_names_detected {
+    use cedar_policy_core::test_utils::ExpectedErrorMessageBuilder;
+    use serde_json::json;
+
+    use super::test_utils::*;
+
+    #[test]
+    fn in_namespace() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                " space": {
+                    "entityTypes": { },
+                    "actions": { },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid namespace ` space`: `Name` needs to be normalized (e.g., whitespace removed):  space").build(),
+            &ExpectedErrorMessageBuilder::error("invalid namespace ` space`: `Name` needs to be normalized (e.g., whitespace removed):  space").build(),
+        );
+    }
+
+    #[test]
+    fn in_common_type() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "commonTypes": {
+                        " space": {"type": "Long"}
+                    },
+                    "entityTypes": { },
+                    "actions": { },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid id ` space`: `Id` needs to be normalized (e.g., whitespace removed):  space").build(),
+            &ExpectedErrorMessageBuilder::error("invalid id ` space`: `Id` needs to be normalized (e.g., whitespace removed):  space").build(),
+        );
+    }
+
+    #[test]
+    fn in_common_type_ref() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": {
+                        "User": {
+                            "shape": {
+                                "type": "Record",
+                                "attributes": {
+                                    "foo": {"type": " space"}
+                                }
+                            }
+                        }
+                    },
+                    "actions": { },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid common type ` space`: `internal name` needs to be normalized (e.g., whitespace removed):  space").build(),
+            &ExpectedErrorMessageBuilder::error("invalid common type ` space`: `internal name` needs to be normalized (e.g., whitespace removed):  space").build(),
+        );
+    }
+
+    #[test]
+    fn reserved_common_type() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "commonTypes": {
+                        "Long": {"type": "Long"}
+                    },
+                    "entityTypes": { },
+                    "actions": { },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error(
+                "this is reserved and cannot be the basename of a common-type declaration: Long",
+            )
+            .build(),
+            &ExpectedErrorMessageBuilder::error(
+                "this is reserved and cannot be the basename of a common-type declaration: Long",
+            )
+            .build(),
+        );
+    }
+
+    #[test]
+    fn in_entity_type() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": {
+                        " space": { }
+                    },
+                    "actions": { },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid id ` space`: `Id` needs to be normalized (e.g., whitespace removed):  space").build(),
+            &ExpectedErrorMessageBuilder::error("invalid id ` space`: `Id` needs to be normalized (e.g., whitespace removed):  space").build(),
+        );
+    }
+
+    #[test]
+    fn in_member_of_types() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": {
+                        "User": {
+                            "memberOfTypes": [" User"]
+                        }
+                    },
+                    "actions": { },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid name ` User`: `internal name` needs to be normalized (e.g., whitespace removed):  User").build(),
+            &ExpectedErrorMessageBuilder::error("invalid name ` User`: `internal name` needs to be normalized (e.g., whitespace removed):  User").build(),
+        );
+    }
+
+    #[test]
+    fn in_entity_type_ref() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": {
+                        "User": {
+                            "shape": {
+                                "type": "Record",
+                                "attributes": {
+                                    "foo": {"type": "Entity", "name": " space"}
+                                }
+                            }
+                        }
+                    },
+                    "actions": { },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid entity type ` space`: `internal name` needs to be normalized (e.g., whitespace removed):  space").build(),
+            &ExpectedErrorMessageBuilder::error("invalid entity type ` space`: `internal name` needs to be normalized (e.g., whitespace removed):  space").build(),
+        );
+    }
+
+    #[test]
+    fn in_extension_type() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": {
+                        "User": {
+                            "shape": {
+                                "type": "Record",
+                                "attributes": {
+                                    "foo": {"type": "Extension", "name": " ip"}
+                                }
+                            }
+                        }
+                    },
+                    "actions": { },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid extension type ` ip`: `Unreserved Id` needs to be normalized (e.g., whitespace removed):  ip").build(),
+            &ExpectedErrorMessageBuilder::error("invalid extension type ` ip`: `Unreserved Id` needs to be normalized (e.g., whitespace removed):  ip").build(),
+        );
+    }
+
+    #[test]
+    fn in_applies_to_principals() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": { },
+                    "actions": {
+                        "act": {
+                            "appliesTo": {
+                                "principalTypes": [" User"],
+                                "resourceTypes": [],
+                            }
+                        }
+                    },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid name ` User`: `internal name` needs to be normalized (e.g., whitespace removed):  User").build(),
+            &ExpectedErrorMessageBuilder::error("invalid name ` User`: `internal name` needs to be normalized (e.g., whitespace removed):  User").build(),
+        );
+    }
+
+    #[test]
+    fn in_applies_to_resources() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": { },
+                    "actions": {
+                        "act": {
+                            "appliesTo": {
+                                "principalTypes": [],
+                                "resourceTypes": [" User"],
+                            }
+                        }
+                    },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid name ` User`: `internal name` needs to be normalized (e.g., whitespace removed):  User").build(),
+            &ExpectedErrorMessageBuilder::error("invalid name ` User`: `internal name` needs to be normalized (e.g., whitespace removed):  User").build(),
+        );
+    }
+
+    #[test]
+    fn in_member_of() {
+        assert_schema_json_err_deprecated_and_standard(
+            json!({
+                "ns": {
+                    "entityTypes": { },
+                    "actions": {
+                        "other": {},
+                        "act": {
+                            "memberOf": [{"type": " Action", "id": "other"}],
+                        }
+                    },
+                }
+            }),
+            &ExpectedErrorMessageBuilder::error("invalid name ` Action`: `internal name` needs to be normalized (e.g., whitespace removed):  Action").build(),
+            &ExpectedErrorMessageBuilder::error("invalid name ` Action`: `internal name` needs to be normalized (e.g., whitespace removed):  Action").build(),
+        );
+    }
+}
+
+#[cfg(test)]
+mod from_str_parse_err {
+
+    use miette::Report;
+
+    use crate::{Schema, SchemaFragment};
+    use cedar_policy_core::test_utils::{expect_err, ExpectedErrorMessageBuilder};
+
+    #[test]
+    #[allow(deprecated)]
+    fn from_cedar_schema_str_err() {
+        let src = "entity User;";
+        expect_err(
+            src,
+            &Report::new(Schema::from_deprecated_json_str(src).unwrap_err()),
+            &ExpectedErrorMessageBuilder::error("expected value at line 1 column 1").help("this API was expecting a schema in the JSON format; did you mean to use a different function, which expects the Cedar schema format?").build(),
+        );
+        expect_err(
+            src,
+            &Report::new(SchemaFragment::from_deprecated_json_str(src).unwrap_err()),
+            &ExpectedErrorMessageBuilder::error("expected value at line 1 column 1").help("this API was expecting a schema in the JSON format; did you mean to use a different function, which expects the Cedar schema format?").build(),
+        );
+    }
+}


Test patch:
diff --git a/cedar-policy-validator/src/deprecated_schema_compat/test.rs b/cedar-policy-validator/src/deprecated_schema_compat/test.rs
new file mode 100644
index 00000000..db8bb9dd
--- /dev/null
+++ b/cedar-policy-validator/src/deprecated_schema_compat/test.rs
@@ -0,0 +1,174 @@
+/*
+ * Copyright Cedar Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+//! Test that a successful parse gives us the expected result. Test in this
+//! crate so we can compare the actual constructed schema data structures. Error
+//! cases are tested from the public API.
+
+use crate::json_schema::Fragment;
+
+// This property unfortunately does not hold in general. If a JSON value parses
+// with in both modes, then the resulting `Fragments` could be different if the
+// 2.5.x compatible parser ignores an attribute which is used by main schema
+// parsers. This specifically affects types containing annotations.
+#[track_caller]
+fn assert_parses_to_same_fragment(schema: serde_json::Value) {
+    let deprecated_parse = Fragment::from_deprecated_json_value(schema.clone()).unwrap();
+    let current_parse = Fragment::from_json_value(schema).unwrap();
+    assert_eq!(deprecated_parse, current_parse);
+}
+
+#[test]
+fn empty() {
+    assert_parses_to_same_fragment(serde_json::json!({}));
+}
+
+#[test]
+fn empty_string_ns() {
+    assert_parses_to_same_fragment(serde_json::json!({
+        "": {
+            "entityTypes": {},
+            "actions": {},
+        }
+    }));
+}
+
+#[test]
+fn multiple_namespace() {
+    assert_parses_to_same_fragment(serde_json::json!({
+        "ns0": {
+            "entityTypes": {},
+            "actions": {},
+        },
+        "ns1": {
+            "entityTypes": {},
+            "actions": {},
+        },
+        "ns2": {
+            "entityTypes": {},
+            "actions": {},
+        }
+    }));
+}
+
+#[test]
+fn declared_common_types() {
+    assert_parses_to_same_fragment(serde_json::json!({
+        "ns": {
+            "commonTypes": {
+                "foo": {"type": "Bool"},
+                "bar": {"type": "foo"},
+                "baz": {"type": "Long"},
+            },
+            "entityTypes": {},
+            "actions": {},
+        }
+    }));
+}
+
+#[test]
+fn member_of_types() {
+    assert_parses_to_same_fragment(serde_json::json!({
+        "ns": {
+            "entityTypes": {
+                "User": { "memberOfTypes": ["Group1", "Group2"] },
+                "Group1": { },
+                "Group2": { }
+            },
+            "actions": { }
+        }
+    }));
+}
+
+#[test]
+fn entity_shape() {
+    assert_parses_to_same_fragment(serde_json::json!({
+        "ns": {
+            "entityTypes": {
+                "User": {
+                    "shape": {
+                        "type": "Record",
+                        "attributes": {
+                            "foo": {"type": "String"},
+                            "bar": {"type": "Entity", "name": "User"}
+                        }
+                    }
+                },
+            },
+            "actions": { }
+        }
+    }));
+}
+
+#[test]
+fn member_of() {
+    assert_parses_to_same_fragment(serde_json::json!({
+        "ns": {
+            "entityTypes": {},
+            "actions": {
+                "foo": { "memberOf": [{"type": "Action", "id": "foo"}, {"id": "baz"}] },
+                "bar": {},
+                "baz": {},
+            },
+        }
+    }));
+}
+
+#[test]
+fn applies_to() {
+    assert_parses_to_same_fragment(serde_json::json!({
+        "ns": {
+            "entityTypes": {
+                "User": {}
+            },
+            "actions": {
+                "foo": {
+                    "appliesTo": {
+                        "principalTypes": ["User"],
+                        "resourceTypes": ["User"],
+                    }
+                }
+            },
+        }
+    }));
+}
+
+#[test]
+fn context() {
+    assert_parses_to_same_fragment(serde_json::json!({
+        "ns": {
+            "entityTypes": { },
+            "actions": {
+                "foo": {
+                    "appliesTo": {
+                        "principalTypes": [],
+                        "resourceTypes": [],
+                        "context": {
+                            "type": "Record",
+                            "attributes": {
+                                "foo": {
+                                    "type": "Set",
+                                    "element": {"type": "Extension", "name": "ip"},
+                                    "required": false,
+                                }
+                            }
+                        }
+                    }
+                }
+            },
+        }
+    }));
+}