Repository: cedar-policy/cedar
Pull request #1603 (2025-05-07 22:40:30)

Build release binaries
## Description of changes

Add a GitHub Actions workflow that gets triggered whenever a release tag is pushed. The generated binaries are attached to the GA run. See for example the attachments in [this action run](https://github.com/adpaco-aws/cedar/actions/runs/14894150588) from my Cedar fork.

There are more things we should do next, mainly to automatically create a release and attach the binaries to it. But it's better to do this incrementally, and there are some process questions I still need to figure out anyway.

## 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).
- [ ] 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`.
- [x] 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):

- [ ] Updates the "Unreleased" section of the CHANGELOG with a description of my change (required for major/minor version bumps).
- [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.
- [ ] 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: ```4a078ec3d38e837da854f6457e3c899bd677492b```

Base Commit: ```a21b62eb330cf334cb90768ed56533f34bdd953c```

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

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

**Actual coverage:** 100.00%

**Status:** PASSED :white_check_mark:

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

| File | Status | Covered | Coverage | Missed Lines |
|:-----|:------:|:-------:|---------:|:-------------|

</details>

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

**Actual coverage:** 81.74%

**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: | 8554/9982 | 85.69% | --
cedar-wasm | :red_circle: | 0/29 | 0.00% | --
</details>


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/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,
+                                }
+                            }
+                        }
+                    }
+                }
+            },
+        }
+    }));
+}