Repository: cedar-policy/cedar
Pull request #1602 (2025-05-07 18:28:39)

Add a typechecking function to check a `Template` by a single env
## 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 change "invisible" to users (e.g., documentation, changes to "internal" crates like `cedar-policy-core`, `cedar-validator`, etc.)

I confirm that this PR (choose one, and delete the other options):

- [x] 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.

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: ```8a6b657b81fe77110475b56efd633fcf7af25f6f```

Base Commit: ```a21b62eb330cf334cb90768ed56533f34bdd953c```

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

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

**Actual coverage:** 76.92%

**Status:** FAILED :x:

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

| File | Status | Covered | Coverage | Missed Lines |
|:-----|:------:|:-------:|---------:|:-------------|
cedar-policy-validator/src/typecheck.rs | :yellow_circle: | 30/39 | 76.92% | 175, 181, 186-192
</details>

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

**Actual coverage:** 81.73%

**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: | 11902/14113 | 84.33% | --
cedar-policy-formatter | :green_circle: | 914/1043 | 87.63% | --
cedar-policy-validator | :green_circle: | 8562/9997 | 85.65% | --
cedar-wasm | :red_circle: | 0/29 | 0.00% | --
</details>

shaobo-he-aws: I think we're safe to ignore the coverage failure. We can add an test for the API in order to meet the coverage criteria but I'm not sure how much it can buy us.
john-h-kastner-aws: > I think we're safe to ignore the coverage failure. We can add an test for the API in order to meet the coverage criteria but I'm not sure how much it can buy us given that this PR essentially is a refactoring.

Maybe `typecheck_by_single_request_env` doesn't need to exist and whatever callers you have in mind can just call `single_env_typechecking` directly. The names are already similar enough to be confusing


Patch:
diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml
new file mode 100644
index 00000000..3994bb59
--- /dev/null
+++ b/.github/workflows/build_release.yml
@@ -0,0 +1,35 @@
+name: Release
+
+on:
+  push:
+    tags:
+      - 'v[0-9]+.[0-9]+.[0-9]+'
+
+jobs:
+  build_release_binaries:
+    strategy:
+      matrix:
+        include:
+          - os: ubuntu-latest
+            target: x86_64-unknown-linux-gnu
+          - os: macos-13
+            target: x86_64-apple-darwin
+          - os: macos-latest
+            target: aarch64-apple-darwin
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - name: Install Rust toolchain
+        run: rustup update stable && rustup default stable
+      - name: Install protobuf (Ubuntu)
+        if: matrix.os == 'ubuntu-latest'
+        run: sudo apt-get install protobuf-compiler
+      - name: Install protobuf (macOS)
+        if: startsWith(matrix.os, 'macos')
+        run: brew install protobuf
+      - name: Build release binaries
+        run: cargo build --release --all-features
+      - uses: actions/upload-artifact@v4
+        with:
+          name: cedar-${{matrix.target}}
+          path: ./target/release/cedar
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-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.
     ///
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,
+                                }
+                            }
+                        }
+                    }
+                }
+            },
+        }
+    }));
+}