Repository: cedar-policy/cedar
Pull request #1415 (2025-01-07 18:12:13)

Make datetime extension an experimental feature
## 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 backwards-compatible change requiring a minor version bump to `cedar-policy` (e.g., addition of a new API).

I confirm that this PR (choose one, and delete the other options):

- [x] Updates the "Unreleased" section of the CHANGELOG with a description of my change (required for major/minor version bumps).

I confirm that [`cedar-spec`](https://github.com/cedar-policy/cedar-spec) (choose one, and delete the other options):

- [x] Does not require updates because my change does not impact the Cedar formal model or DRT infrastructure.

I confirm that [`docs.cedarpolicy.com`](https://docs.cedarpolicy.com/) (choose one, and delete the other options):

- [x] 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`.)



shaobo-he-aws: > Why did you undo the change to remove `datetime` as a default feature in the core and validator crates? I would think that's what we want

I thought that we an experimental feature is meant to apply to cedar-policy since it contains public APIs. Do you think that we should provide the same guarantee in core and validator (i.e., default features should be DRTed) as well?
john-h-kastner-aws: > Do you think that we should provide the same guarantee in core and validator (i.e., default features should be DRTed) as well?

This is more a question of how Cargo will handle this feature. If `datetime` is default in `cedar-policy-core`, and `cedar-policy` depends on it without `no-default-features`, then I would expect that the `datetime` extension would be enabled when parsing Cedar policies even when it's not explicitly enabled by the consumer of `cedar-policy`
shaobo-he-aws: > This is more a question about how Cargo will handle this feature. If `datetime` is default in `cedar-policy-core`, and `cedar-policy` depends on it without `no-default-features`, then I would expect that the `datetime` extension would be enabled when parsing Cedar policies even when it's not explicitly enabled by the consumer of `cedar-policy`

I see. It makes sense. I'll revert the latest commit.


Patch:
diff --git a/cedar-policy-core/Cargo.toml b/cedar-policy-core/Cargo.toml
index 0b25a5ff..2870caab 100644
--- a/cedar-policy-core/Cargo.toml
+++ b/cedar-policy-core/Cargo.toml
@@ -46,7 +46,7 @@ prost = { version = "0.13", optional = true }
 
 [features]
 # by default, enable all Cedar extensions
-default = ["ipaddr", "decimal", "datetime"]
+default = ["ipaddr", "decimal"]
 ipaddr = []
 decimal = ["dep:regex"]
 datetime = ["dep:chrono", "dep:regex"]
diff --git a/cedar-policy-core/src/ast/extension.rs b/cedar-policy-core/src/ast/extension.rs
index 559af3f2..d8161ca8 100644
--- a/cedar-policy-core/src/ast/extension.rs
+++ b/cedar-policy-core/src/ast/extension.rs
@@ -14,35 +14,15 @@
  * limitations under the License.
  */
 
-pub use names::TYPES_WITH_OPERATOR_OVERLOADING;
-
 use crate::ast::*;
 use crate::entities::SchemaType;
 use crate::evaluator;
 use std::any::Any;
-use std::collections::HashMap;
+use std::collections::{BTreeSet, HashMap};
 use std::fmt::Debug;
 use std::panic::{RefUnwindSafe, UnwindSafe};
 use std::sync::Arc;
 
-// PANIC SAFETY: `Name`s in here are valid `Name`s
-#[allow(clippy::expect_used)]
-mod names {
-    use std::collections::BTreeSet;
-
-    use super::Name;
-
-    lazy_static::lazy_static! {
-        /// Extension type names that support operator overloading
-        // INVARIANT: this set must not be empty.
-        pub static ref TYPES_WITH_OPERATOR_OVERLOADING : BTreeSet<Name> =
-            BTreeSet::from_iter(
-                [Name::parse_unqualified_name("datetime").expect("valid identifier"),
-                 Name::parse_unqualified_name("duration").expect("valid identifier")]
-            );
-    }
-}
-
 /// Cedar extension.
 ///
 /// An extension can define new types and functions on those types. (Currently,
@@ -54,14 +34,21 @@ pub struct Extension {
     name: Name,
     /// Extension functions. These are legal to call in Cedar expressions.
     functions: HashMap<Name, ExtensionFunction>,
+    /// Types with operator overloading
+    types_with_operator_overloading: BTreeSet<Name>,
 }
 
 impl Extension {
     /// Create a new `Extension` with the given name and extension functions
-    pub fn new(name: Name, functions: impl IntoIterator<Item = ExtensionFunction>) -> Self {
+    pub fn new(
+        name: Name,
+        functions: impl IntoIterator<Item = ExtensionFunction>,
+        types_with_operator_overloading: impl IntoIterator<Item = Name>,
+    ) -> Self {
         Self {
             name,
             functions: functions.into_iter().map(|f| (f.name.clone(), f)).collect(),
+            types_with_operator_overloading: types_with_operator_overloading.into_iter().collect(),
         }
     }
 
@@ -86,6 +73,11 @@ impl Extension {
     pub fn ext_types(&self) -> impl Iterator<Item = &Name> + '_ {
         self.funcs().flat_map(|func| func.ext_types())
     }
+
+    /// Iterate over extension types with operator overloading
+    pub fn types_with_operator_overloading(&self) -> impl Iterator<Item = &Name> + '_ {
+        self.types_with_operator_overloading.iter()
+    }
 }
 
 impl std::fmt::Debug for Extension {
diff --git a/cedar-policy-core/src/authorizer/partial_response.rs b/cedar-policy-core/src/authorizer/partial_response.rs
index fdf3b500..8059babd 100644
--- a/cedar-policy-core/src/authorizer/partial_response.rs
+++ b/cedar-policy-core/src/authorizer/partial_response.rs
@@ -337,23 +337,11 @@ impl PartialResponse {
     ) -> Result<Request, ConcretizationError> {
         let mut context = self.request.context.clone();
 
-        let principal = if let Some((key, val)) = mapping.get_key_value("principal") {
-            self.request.principal().concretize(key, val)?
-        } else {
-            self.request.principal().clone()
-        };
+        let principal = self.request.principal().concretize("principal", mapping)?;
 
-        let action = if let Some((key, val)) = mapping.get_key_value("action") {
-            self.request.action().concretize(key, val)?
-        } else {
-            self.request.action().clone()
-        };
+        let action = self.request.action.concretize("action", mapping)?;
 
-        let resource = if let Some((key, val)) = mapping.get_key_value("resource") {
-            self.request.resource().concretize(key, val)?
-        } else {
-            self.request.resource().clone()
-        };
+        let resource = self.request.resource.concretize("resource", mapping)?;
 
         if let Some((key, val)) = mapping.get_key_value("context") {
             if let Ok(attrs) = val.get_as_record() {
@@ -406,38 +394,48 @@ impl PartialResponse {
 }
 
 impl EntityUIDEntry {
-    fn concretize(&self, key: &SmolStr, val: &Value) -> Result<Self, ConcretizationError> {
-        if let Ok(uid) = val.get_as_entity() {
-            match self {
-                EntityUIDEntry::Known { euid, .. } => Err(ConcretizationError::VarConfictError {
-                    id: key.to_owned(),
-                    existing_value: euid.as_ref().clone().into(),
-                    given_value: val.clone(),
-                }),
-                EntityUIDEntry::Unknown { ty: None, .. } => {
-                    Ok(EntityUIDEntry::known(uid.clone(), None))
-                }
-                EntityUIDEntry::Unknown {
-                    ty: Some(type_of_unknown),
-                    ..
-                } => {
-                    if type_of_unknown == uid.entity_type() {
-                        Ok(EntityUIDEntry::known(uid.clone(), None))
-                    } else {
-                        Err(ConcretizationError::EntityTypeConfictError {
-                            id: key.to_owned(),
-                            existing_value: type_of_unknown.clone(),
-                            given_value: val.to_owned(),
+    fn concretize(
+        &self,
+        key: &str,
+        mapping: &HashMap<SmolStr, Value>,
+    ) -> Result<Self, ConcretizationError> {
+        if let Some(val) = mapping.get(key) {
+            if let Ok(uid) = val.get_as_entity() {
+                match self {
+                    EntityUIDEntry::Known { euid, .. } => {
+                        Err(ConcretizationError::VarConfictError {
+                            id: key.into(),
+                            existing_value: euid.as_ref().clone().into(),
+                            given_value: val.clone(),
                         })
                     }
+                    EntityUIDEntry::Unknown { ty: None, .. } => {
+                        Ok(EntityUIDEntry::known(uid.clone(), None))
+                    }
+                    EntityUIDEntry::Unknown {
+                        ty: Some(type_of_unknown),
+                        ..
+                    } => {
+                        if type_of_unknown == uid.entity_type() {
+                            Ok(EntityUIDEntry::known(uid.clone(), None))
+                        } else {
+                            Err(ConcretizationError::EntityTypeConfictError {
+                                id: key.into(),
+                                existing_value: type_of_unknown.clone(),
+                                given_value: val.to_owned(),
+                            })
+                        }
+                    }
                 }
+            } else {
+                Err(ConcretizationError::ValueError {
+                    id: key.into(),
+                    expected_type: "entity",
+                    given_value: val.to_owned(),
+                })
             }
         } else {
-            Err(ConcretizationError::ValueError {
-                id: key.to_owned(),
-                expected_type: "entity",
-                given_value: val.to_owned(),
-            })
+            Ok(self.clone())
         }
     }
 }
diff --git a/cedar-policy-core/src/evaluator.rs b/cedar-policy-core/src/evaluator.rs
index 5eb158a5..eb73c656 100644
--- a/cedar-policy-core/src/evaluator.rs
+++ b/cedar-policy-core/src/evaluator.rs
@@ -30,7 +30,7 @@ pub use err::evaluation_errors;
 pub use err::EvaluationError;
 pub(crate) use err::*;
 use evaluation_errors::*;
-use itertools::Either;
+use itertools::{Either, Itertools};
 use nonempty::nonempty;
 use smol_str::SmolStr;
 
@@ -185,6 +185,16 @@ impl<'e> RestrictedEvaluator<'e> {
     }
 }
 
+pub(crate) fn valid_comparison_op_types(extensions: &Extensions<'_>) -> nonempty::NonEmpty<Type> {
+    let mut expected_types = nonempty::NonEmpty::singleton(Type::Long);
+    expected_types.extend(
+        extensions
+            .types_with_operator_overloading()
+            .map(|n| Type::Extension { name: n.clone() }),
+    );
+    expected_types
+}
+
 impl<'e> Evaluator<'e> {
     /// Create a fresh `Evaluator` for the given `request`, which uses the given
     /// `Entities` to resolve entity references. Use the given `Extension`s when
@@ -380,20 +390,20 @@ impl<'e> Evaluator<'e> {
                 // NOTE: There are more precise partial eval opportunities here, esp w/ typed unknowns
                 // Current limitations:
                 //   Operators are not partially evaluated, except in a few 'simple' cases when comparing a concrete value with an unknown of known type
-                //   implemented in short_circuit_typed_residual
+                //   implemented in short_circuit_*
                 let (arg1, arg2) = match (
                     self.partial_interpret(arg1, slots)?,
                     self.partial_interpret(arg2, slots)?,
                 ) {
                     (PartialValue::Value(v1), PartialValue::Value(v2)) => (v1, v2),
                     (PartialValue::Value(v1), PartialValue::Residual(e2)) => {
-                        if let Some(val) = self.short_circuit_typed_residual(&v1, &e2, *op) {
+                        if let Some(val) = self.short_circuit_value_and_residual(&v1, &e2, *op) {
                             return Ok(val);
                         }
                         return Ok(PartialValue::Residual(Expr::binary_app(*op, v1.into(), e2)));
                     }
                     (PartialValue::Residual(e1), PartialValue::Value(v2)) => {
-                        if let Some(val) = self.short_circuit_typed_residual(&v2, &e1, *op) {
+                        if let Some(val) = self.short_circuit_residual_and_value(&e1, &v2, *op) {
                             return Ok(val);
                         }
                         return Ok(PartialValue::Residual(Expr::binary_app(*op, e1, v2.into())));
@@ -432,15 +442,38 @@ impl<'e> Evaluator<'e> {
                                 Ok(ext_op(x, y).into())
                             }
                             // throw type errors
-                            (ValueKind::Lit(Literal::Long(_)), _) => Err(EvaluationError::type_error_single(Type::Long, &arg2)),
-                            (_, ValueKind::Lit(Literal::Long(_))) => Err(EvaluationError::type_error_single(Type::Long, &arg1)),
-                            (ValueKind::ExtensionValue(x), _) if x.supports_operator_overloading() => Err(EvaluationError::type_error_single(Type::Extension { name: x.typename() }, &arg2)),
-                            (_, ValueKind::ExtensionValue(y)) if y.supports_operator_overloading() => Err(EvaluationError::type_error_single(Type::Extension { name: y.typename() }, &arg1)),
-                            (ValueKind::ExtensionValue(x), ValueKind::ExtensionValue(y)) if x.typename() == y.typename() => Err(EvaluationError::type_error_with_advice(Extensions::types_with_operator_overloading().map(|name| Type::Extension { name} ), &arg1, "Only extension types `datetime` and `duration` support operator overloading".to_string())),
+                            (ValueKind::Lit(Literal::Long(_)), _) => {
+                                Err(EvaluationError::type_error_single(Type::Long, &arg2))
+                            }
+                            (_, ValueKind::Lit(Literal::Long(_))) => {
+                                Err(EvaluationError::type_error_single(Type::Long, &arg1))
+                            }
+                            (ValueKind::ExtensionValue(x), _)
+                                if x.supports_operator_overloading() =>
+                            {
+                                Err(EvaluationError::type_error_single(
+                                    Type::Extension { name: x.typename() },
+                                    &arg2,
+                                ))
+                            }
+                            (_, ValueKind::ExtensionValue(y))
+                                if y.supports_operator_overloading() =>
+                            {
+                                Err(EvaluationError::type_error_single(
+                                    Type::Extension { name: y.typename() },
+                                    &arg1,
+                                ))
+                            }
                             _ => {
-                                let mut expected_types = Extensions::types_with_operator_overloading().map(|name| Type::Extension { name });
-                                expected_types.push(Type::Long);
-                                Err(EvaluationError::type_error_with_advice(expected_types, &arg1, "Only `Long` and extension types `datetime`, `duration` support comparison".to_string()))
+                                let expected_types = valid_comparison_op_types(&self.extensions);
+                                Err(EvaluationError::type_error_with_advice(
+                                    expected_types.clone(),
+                                    &arg1,
+                                    format!(
+                                        "Only types {} support comparison",
+                                        expected_types.into_iter().sorted().join(", ")
+                                    ),
+                                ))
                             }
                         }
                     }
@@ -922,10 +955,26 @@ impl<'e> Evaluator<'e> {
         }
     }
 
-    /// Evaluate a binary operation between a value and a residual expression. If despite the unknown contained in the residual, concrete result
+    /// Evaluate a binary operation between a residual expression (left) and a value (right). If despite the unknown contained in the residual, concrete result
+    /// can be obtained (using the type annotation on the residual), it is returned.
+    fn short_circuit_residual_and_value(
+        &self,
+        e1: &Expr,
+        v2: &Value,
+        op: BinaryOp,
+    ) -> Option<PartialValue> {
+        match op {
+            // Since these operators are commutative, we can use just one order, and have one implementation of the actual logic
+            BinaryOp::Add | BinaryOp::Eq | BinaryOp::Mul | BinaryOp::ContainsAny => {
+                self.short_circuit_value_and_residual(v2, e1, op)
+            }
+            _ => None,
+        }
+    }
+
+    /// Evaluate a binary operation between a value (left) and a residual expression (right). If despite the unknown contained in the residual, concrete result
     /// can be obtained (using the type annotation on the residual), it is returned.
-    /// Since it is not aware which of the inputs is on the left side, and which on the right, it needs to return None for all non-commutative operations.
-    fn short_circuit_typed_residual(
+    fn short_circuit_value_and_residual(
         &self,
         v1: &Value,
         e2: &Expr,
@@ -2935,7 +2984,18 @@ pub(crate) mod test {
     fn interpret_compares() {
         let request = basic_request();
         let entities = basic_entities();
-        let eval = Evaluator::new(request, &entities, Extensions::all_available());
+        let extensions = Extensions::all_available();
+        let eval = Evaluator::new(request, &entities, extensions);
+        let expected_types = valid_comparison_op_types(extensions);
+        let assert_type_error = |expr, actual_type| {
+            assert_matches!(
+                eval.interpret_inline_policy(&expr),
+                Err(EvaluationError::TypeError(TypeError { expected, actual, .. })) => {
+                    assert_eq!(expected, expected_types.clone());
+                    assert_eq!(actual, actual_type);
+                }
+            );
+        };
         // 3 < 303
         assert_eq!(
             eval.interpret_inline_policy(&Expr::less(Expr::val(3), Expr::val(303))),
@@ -2997,214 +3057,91 @@ pub(crate) mod test {
             Ok(Value::from(true))
         );
         // false < true
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::less(Expr::val(false), Expr::val(true))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::Bool);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::less(Expr::val(false), Expr::val(true)), Type::Bool);
+
         // false < false
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::less(Expr::val(false), Expr::val(false))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::Bool);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::less(Expr::val(false), Expr::val(false)), Type::Bool);
+
         // true <= false
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::lesseq(Expr::val(true), Expr::val(false))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::Bool);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::lesseq(Expr::val(true), Expr::val(false)), Type::Bool);
+
         // false <= false
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::lesseq(Expr::val(false), Expr::val(false))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected,nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::Bool);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::lesseq(Expr::val(false), Expr::val(false)), Type::Bool);
+
         // false > true
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::greater(Expr::val(false), Expr::val(true))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::Bool);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::greater(Expr::val(false), Expr::val(true)), Type::Bool);
+
         // true > true
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::greater(Expr::val(true), Expr::val(true))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::Bool);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::greater(Expr::val(true), Expr::val(true)), Type::Bool);
+
         // true >= false
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::greatereq(Expr::val(true), Expr::val(false))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::Bool);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+        assert_type_error(
+            Expr::greatereq(Expr::val(true), Expr::val(false)),
+            Type::Bool,
         );
+
         // true >= true
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::greatereq(Expr::val(true), Expr::val(true))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::Bool);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+        assert_type_error(
+            Expr::greatereq(Expr::val(true), Expr::val(true)),
+            Type::Bool,
         );
+
         // bc < zzz
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::less(Expr::val("bc"), Expr::val("zzz"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::less(Expr::val("bc"), Expr::val("zzz")), Type::String);
         // banana < zzz
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::less(Expr::val("banana"), Expr::val("zzz"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+        assert_type_error(
+            Expr::less(Expr::val("banana"), Expr::val("zzz")),
+            Type::String,
         );
         // "" < zzz
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::less(Expr::val(""), Expr::val("zzz"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::less(Expr::val(""), Expr::val("zzz")), Type::String);
         // a < 1
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::less(Expr::val("a"), Expr::val("1"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::less(Expr::val("a"), Expr::val("1")), Type::String);
         // a < A
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::less(Expr::val("a"), Expr::val("A"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::less(Expr::val("a"), Expr::val("A")), Type::String);
         // A < A
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::less(Expr::val("A"), Expr::val("A"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::less(Expr::val("A"), Expr::val("A")), Type::String);
         // zebra < zebras
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::less(Expr::val("zebra"), Expr::val("zebras"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+        assert_type_error(
+            Expr::less(Expr::val("zebra"), Expr::val("zebras")),
+            Type::String,
         );
         // zebra <= zebras
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::lesseq(Expr::val("zebra"), Expr::val("zebras"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+        assert_type_error(
+            Expr::lesseq(Expr::val("zebra"), Expr::val("zebras")),
+            Type::String,
         );
         // zebras <= zebras
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::lesseq(Expr::val("zebras"), Expr::val("zebras"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+        assert_type_error(
+            Expr::lesseq(Expr::val("zebras"), Expr::val("zebras")),
+            Type::String,
         );
         // zebras <= Zebras
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::lesseq(Expr::val("zebras"), Expr::val("Zebras"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+        assert_type_error(
+            Expr::lesseq(Expr::val("zebras"), Expr::val("Zebras")),
+            Type::String,
         );
         // 123 > 78
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::greater(Expr::val("123"), Expr::val("78"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+        assert_type_error(
+            Expr::greater(Expr::val("123"), Expr::val("78")),
+            Type::String,
         );
         // <space>zebras >= zebras
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::greatereq(
-                Expr::val(" zebras"),
-                Expr::val("zebras")
-            )),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+        assert_type_error(
+            Expr::greatereq(Expr::val(" zebras"), Expr::val("zebras")),
+            Type::String,
         );
         // "" >= ""
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::greatereq(Expr::val(""), Expr::val(""))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
-        );
+        assert_type_error(Expr::greatereq(Expr::val(""), Expr::val("")), Type::String);
         // "" >= _hi
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::greatereq(Expr::val(""), Expr::val("_hi"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+        assert_type_error(
+            Expr::greatereq(Expr::val(""), Expr::val("_hi")),
+            Type::String,
         );
         // 🦀 >= _hi
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::greatereq(Expr::val("🦀"), Expr::val("_hi"))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::String);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+        assert_type_error(
+            Expr::greatereq(Expr::val("🦀"), Expr::val("_hi")),
+            Type::String,
         );
         // 2 < "4"
         assert_matches!(
@@ -3243,18 +3180,22 @@ pub(crate) mod test {
             }
         );
         // [1, 2] < [47, 0]
-        assert_matches!(
-            eval.interpret_inline_policy(&Expr::less(
+        assert_type_error(
+            Expr::less(
                 Expr::set(vec![Expr::val(1), Expr::val(2)]),
-                Expr::set(vec![Expr::val(47), Expr::val(0)])
-            )),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}, Type::Long]);
-                assert_eq!(actual, Type::Set);
-                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
-            }
+                Expr::set(vec![Expr::val(47), Expr::val(0)]),
+            ),
+            Type::Set,
         );
+    }
 
+    #[cfg(feature = "datetime")]
+    #[test]
+    fn interpret_datetime_extension_compares() {
+        let request = basic_request();
+        let entities = basic_entities();
+        let extensions = Extensions::all_available();
+        let eval = Evaluator::new(request, &entities, extensions);
         let datetime_constructor: Name = "datetime".parse().unwrap();
         let duration_constructor: Name = "duration".parse().unwrap();
         assert_matches!(eval.interpret_inline_policy(
@@ -3478,10 +3419,9 @@ pub(crate) mod test {
                 Expr::call_extension_fn(
                     "decimal".parse().unwrap(),
                     vec![Value::from("3.0").into()]))),
-            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: datetime_constructor }, Type::Extension { name: duration_constructor }]);
+            Err(EvaluationError::TypeError(TypeError { expected, actual, .. })) => {
+                assert_eq!(expected, valid_comparison_op_types(extensions));
                 assert_eq!(actual, Type::Extension { name: "decimal".parse().unwrap() });
-                assert_eq!(advice, Some("Only extension types `datetime` and `duration` support operator overloading".into()));
         });
     }
 
diff --git a/cedar-policy-core/src/extensions.rs b/cedar-policy-core/src/extensions.rs
index 57bf8587..bfb66c91 100644
--- a/cedar-policy-core/src/extensions.rs
+++ b/cedar-policy-core/src/extensions.rs
@@ -28,11 +28,10 @@ pub mod partial_evaluation;
 
 use std::collections::HashMap;
 
-use crate::ast::{Extension, ExtensionFunction, Name, TYPES_WITH_OPERATOR_OVERLOADING};
+use crate::ast::{Extension, ExtensionFunction, Name};
 use crate::entities::SchemaType;
 use crate::parser::Loc;
 use miette::Diagnostic;
-use nonempty::NonEmpty;
 use thiserror::Error;
 
 use self::extension_function_lookup_errors::FuncDoesNotExistError;
@@ -98,21 +97,15 @@ impl Extensions<'static> {
     pub fn none() -> &'static Extensions<'static> {
         &EXTENSIONS_NONE
     }
-
-    /// Obtain the non-empty vector of types supporting operator overloading
-    pub fn types_with_operator_overloading() -> NonEmpty<Name> {
-        // PANIC SAFETY: There are more than one element in `TYPES_WITH_OPERATOR_OVERLOADING`
-        #[allow(clippy::unwrap_used)]
-        NonEmpty::collect(TYPES_WITH_OPERATOR_OVERLOADING.iter().cloned()).unwrap()
-    }
-
-    /// Iterate over extension types that support operator overloading
-    pub fn iter_type_with_operator_overloading() -> impl Iterator<Item = &'static Name> {
-        TYPES_WITH_OPERATOR_OVERLOADING.iter()
-    }
 }
 
 impl<'a> Extensions<'a> {
+    /// Obtain the non-empty vector of types supporting operator overloading
+    pub fn types_with_operator_overloading(&self) -> impl Iterator<Item = &Name> + '_ {
+        self.extensions
+            .iter()
+            .flat_map(|ext| ext.types_with_operator_overloading())
+    }
     /// Get a new `Extensions` with these specific extensions enabled.
     pub fn specific_extensions(
         extensions: &'a [Extension],
diff --git a/cedar-policy-core/src/extensions/datetime.rs b/cedar-policy-core/src/extensions/datetime.rs
index b98c1185..3327ee4b 100644
--- a/cedar-policy-core/src/extensions/datetime.rs
+++ b/cedar-policy-core/src/extensions/datetime.rs
@@ -718,6 +718,10 @@ pub fn extension() -> Extension {
                 duration_type,
             ),
         ],
+        [
+            DATETIME_CONSTRUCTOR_NAME.clone(),
+            DURATION_CONSTRUCTOR_NAME.clone(),
+        ],
     )
 }
 
diff --git a/cedar-policy-core/src/extensions/decimal.rs b/cedar-policy-core/src/extensions/decimal.rs
index bd601a95..3cd6bd77 100644
--- a/cedar-policy-core/src/extensions/decimal.rs
+++ b/cedar-policy-core/src/extensions/decimal.rs
@@ -308,6 +308,7 @@ pub fn extension() -> Extension {
                 (decimal_type.clone(), decimal_type),
             ),
         ],
+        std::iter::empty(),
     )
 }
 
@@ -627,12 +628,12 @@ mod tests {
                 &parse_expr(r#"decimal("1.23") < decimal("1.24")"#).expect("parsing error")
             ),
             Err(EvaluationError::TypeError(evaluation_errors::TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}]);
+                assert_eq!(expected, nonempty![Type::Long]);
                 assert_eq!(actual, Type::Extension {
                     name: Name::parse_unqualified_name("decimal")
                         .expect("should be a valid identifier")
                 });
-                assert_eq!(advice, Some("Only extension types `datetime` and `duration` support operator overloading".into()));
+                assert_eq!(advice, Some("Only types long support comparison".into()));
             }
         );
         assert_matches!(
diff --git a/cedar-policy-core/src/extensions/ipaddr.rs b/cedar-policy-core/src/extensions/ipaddr.rs
index ed88f790..fa0932f9 100644
--- a/cedar-policy-core/src/extensions/ipaddr.rs
+++ b/cedar-policy-core/src/extensions/ipaddr.rs
@@ -438,6 +438,7 @@ pub fn extension() -> Extension {
                 (ipaddr_type.clone(), ipaddr_type),
             ),
         ],
+        std::iter::empty(),
     )
 }
 
@@ -609,12 +610,12 @@ mod tests {
         assert_matches!(
             eval.interpret_inline_policy(&Expr::less(ip("127.0.0.1"), ip("10.0.0.10"))),
             Err(EvaluationError::TypeError(evaluation_errors::TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}]);
+                assert_eq!(expected, nonempty![Type::Long]);
                 assert_eq!(actual, Type::Extension {
                     name: Name::parse_unqualified_name("ipaddr")
                         .expect("should be a valid identifier")
                 });
-                assert_eq!(advice, Some("Only extension types `datetime` and `duration` support operator overloading".into()));
+                assert_eq!(advice, Some("Only types long support comparison".into()));
             }
         );
         // test that isIpv4 on a String is an error
diff --git a/cedar-policy-core/src/extensions/partial_evaluation.rs b/cedar-policy-core/src/extensions/partial_evaluation.rs
index c271c55b..63ba9af4 100644
--- a/cedar-policy-core/src/extensions/partial_evaluation.rs
+++ b/cedar-policy-core/src/extensions/partial_evaluation.rs
@@ -41,5 +41,6 @@ pub fn extension() -> Extension {
             Box::new(create_new_unknown),
             SchemaType::String,
         )],
+        std::iter::empty(),
     )
 }
diff --git a/cedar-policy-validator/Cargo.toml b/cedar-policy-validator/Cargo.toml
index ec7cc59e..a5f6de4e 100644
--- a/cedar-policy-validator/Cargo.toml
+++ b/cedar-policy-validator/Cargo.toml
@@ -38,7 +38,7 @@ prost = { version = "0.13", optional = true }
 
 [features]
 # by default, enable all Cedar extensions
-default = ["ipaddr", "decimal", "datetime"]
+default = ["ipaddr", "decimal"]
 # when enabling a feature, make sure that the Core feature is also enabled
 ipaddr = ["cedar-policy-core/ipaddr"]
 decimal = ["cedar-policy-core/decimal"]
diff --git a/cedar-policy-validator/src/extension_schema.rs b/cedar-policy-validator/src/extension_schema.rs
index 764b5264..648f017e 100644
--- a/cedar-policy-validator/src/extension_schema.rs
+++ b/cedar-policy-validator/src/extension_schema.rs
@@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+use std::collections::BTreeSet;
+
 use crate::types::Type;
 use cedar_policy_core::ast::{Expr, Name};
 
@@ -23,6 +25,8 @@ pub struct ExtensionSchema {
     name: Name,
     /// Type information for extension functions
     function_types: Vec<ExtensionFunctionType>,
+    /// Types that support operator overloading
+    types_with_operator_overloading: BTreeSet<Name>,
 }
 
 impl std::fmt::Debug for ExtensionSchema {
@@ -36,10 +40,12 @@ impl ExtensionSchema {
     pub fn new(
         name: Name,
         function_types: impl IntoIterator<Item = ExtensionFunctionType>,
+        types_with_operator_overloading: impl IntoIterator<Item = Name>,
     ) -> Self {
         Self {
             name,
             function_types: function_types.into_iter().collect(),
+            types_with_operator_overloading: types_with_operator_overloading.into_iter().collect(),
         }
     }
 
@@ -51,6 +57,11 @@ impl ExtensionSchema {
     pub fn function_types(&self) -> impl Iterator<Item = &ExtensionFunctionType> {
         self.function_types.iter()
     }
+
+    /// Get all extension types that support operator overloading
+    pub fn types_with_operator_overloading(&self) -> impl Iterator<Item = &Name> {
+        self.types_with_operator_overloading.iter()
+    }
 }
 
 /// The type of a function used to perform custom argument validation on an
diff --git a/cedar-policy-validator/src/extensions.rs b/cedar-policy-validator/src/extensions.rs
index da393d13..937a0db5 100644
--- a/cedar-policy-validator/src/extensions.rs
+++ b/cedar-policy-validator/src/extensions.rs
@@ -16,7 +16,7 @@
 
 //! This module contains type information for all of the standard Cedar extensions.
 
-use std::collections::HashMap;
+use std::collections::{BTreeSet, HashMap};
 
 use cedar_policy_core::{
     ast::{Name, RestrictedExpr, Value},
@@ -57,7 +57,7 @@ lazy_static::lazy_static! {
     static ref ALL_AVAILABLE_EXTENSION_SCHEMAS : ExtensionSchemas<'static> = ExtensionSchemas::build_all_available();
 }
 
-/// Aggregate structure containing function signatures for multiple [`ExtensionSchema`].
+/// Aggregate structure containing information such as function signatures for multiple [`ExtensionSchema`].
 /// Ensures that no function name is defined mode than once.
 /// Intentionally does not derive `Clone` to avoid clones of the `HashMap`. For the
 /// moment, it's easy to pass this around by reference. We could make this
@@ -69,6 +69,8 @@ pub struct ExtensionSchemas<'a> {
     /// extension function lookup that at most one extension functions exists
     /// for a name.
     function_types: HashMap<&'a Name, &'a ExtensionFunctionType>,
+    /// Extension types that support operator overloading
+    types_with_operator_overloading: BTreeSet<&'a Name>,
 }
 
 impl<'a> ExtensionSchemas<'a> {
@@ -98,7 +100,16 @@ impl<'a> ExtensionSchemas<'a> {
         )
         .map_err(|name| FuncMultiplyDefinedError { name: name.clone() })?;
 
-        Ok(Self { function_types })
+        // We already ensure that names of extension types do not collide, at the language level
+        let types_with_operator_overloading = extension_schemas
+            .iter()
+            .flat_map(|f| f.types_with_operator_overloading())
+            .collect();
+
+        Ok(Self {
+            function_types,
+            types_with_operator_overloading,
+        })
     }
 
     /// Get the [`ExtensionFunctionType`] for a function with this [`Name`].
@@ -106,6 +117,16 @@ impl<'a> ExtensionSchemas<'a> {
     pub fn func_type(&self, name: &Name) -> Option<&ExtensionFunctionType> {
         self.function_types.get(name).copied()
     }
+
+    /// Query if `ext_ty_name` supports operator overloading
+    pub fn has_type_with_operator_overloading(&self, ext_ty_name: &Name) -> bool {
+        self.types_with_operator_overloading.contains(ext_ty_name)
+    }
+
+    /// Get all extension types that support operator overloading
+    pub fn types_with_operator_overloading(&self) -> impl Iterator<Item = &Name> + '_ {
+        self.types_with_operator_overloading.iter().cloned()
+    }
 }
 
 /// Evaluates ane extension function on a single string literal argument. Used
diff --git a/cedar-policy-validator/src/extensions/datetime.rs b/cedar-policy-validator/src/extensions/datetime.rs
index 5425992b..a337591c 100644
--- a/cedar-policy-validator/src/extensions/datetime.rs
+++ b/cedar-policy-validator/src/extensions/datetime.rs
@@ -97,7 +97,8 @@ pub fn extension_schema() -> ExtensionSchema {
     let datetime_ty = Type::extension(datetime_ext.name().clone());
     //PANIC SAFETY: `duration` is a valid name
     #[allow(clippy::unwrap_used)]
-    let duration_ty = Type::extension("duration".parse().unwrap());
+    let duration_ty_name: Name = "duration".parse().unwrap();
+    let duration_ty = Type::extension(duration_ty_name.clone());
 
     let fun_tys = datetime_ext.funcs().map(|f| {
         let return_type = get_return_type(f.name(), &datetime_ty, &duration_ty);
@@ -112,7 +113,11 @@ pub fn extension_schema() -> ExtensionSchema {
             get_argument_check(f.name()),
         )
     });
-    ExtensionSchema::new(datetime_ext.name().clone(), fun_tys)
+    ExtensionSchema::new(
+        datetime_ext.name().clone(),
+        fun_tys,
+        [datetime_ext.name().clone(), duration_ty_name],
+    )
 }
 
 /// Extra validation step for the `datetime` function.
diff --git a/cedar-policy-validator/src/extensions/decimal.rs b/cedar-policy-validator/src/extensions/decimal.rs
index 4916b5f5..37758f76 100644
--- a/cedar-policy-validator/src/extensions/decimal.rs
+++ b/cedar-policy-validator/src/extensions/decimal.rs
@@ -97,7 +97,7 @@ pub fn extension_schema() -> ExtensionSchema {
             get_argument_check(f.name()),
         )
     });
-    ExtensionSchema::new(decimal_ext.name().clone(), fun_tys)
+    ExtensionSchema::new(decimal_ext.name().clone(), fun_tys, std::iter::empty())
 }
 
 /// Extra validation step for the `decimal` function.
diff --git a/cedar-policy-validator/src/extensions/ipaddr.rs b/cedar-policy-validator/src/extensions/ipaddr.rs
index eef5b470..f1fd7bf6 100644
--- a/cedar-policy-validator/src/extensions/ipaddr.rs
+++ b/cedar-policy-validator/src/extensions/ipaddr.rs
@@ -96,7 +96,7 @@ pub fn extension_schema() -> ExtensionSchema {
             get_argument_check(f.name()),
         )
     });
-    ExtensionSchema::new(ipaddr_ext.name().clone(), fun_tys)
+    ExtensionSchema::new(ipaddr_ext.name().clone(), fun_tys, std::iter::empty())
 }
 
 /// Extra validation step for the `ip` function.
diff --git a/cedar-policy-validator/src/extensions/partial_evaluation.rs b/cedar-policy-validator/src/extensions/partial_evaluation.rs
index 450ac5ba..4bfdc094 100644
--- a/cedar-policy-validator/src/extensions/partial_evaluation.rs
+++ b/cedar-policy-validator/src/extensions/partial_evaluation.rs
@@ -67,7 +67,7 @@ pub fn extension_schema() -> ExtensionSchema {
             None,
         )
     });
-    ExtensionSchema::new(pe_ext.name().clone(), fun_tys)
+    ExtensionSchema::new(pe_ext.name().clone(), fun_tys, std::iter::empty())
 }
 
 #[cfg(test)]
diff --git a/cedar-policy-validator/src/schema.rs b/cedar-policy-validator/src/schema.rs
index eb37ddcb..c5505836 100644
--- a/cedar-policy-validator/src/schema.rs
+++ b/cedar-policy-validator/src/schema.rs
@@ -3073,32 +3073,35 @@ pub(crate) mod test {
                     .build());
         });
 
-        let src: serde_json::Value = json!({
-            "": {
-                "commonTypes": {
-                    "ty": {
-                        "type": "Record",
-                        "attributes": {
-                            "a": {
-                                "type": "Extension",
-                                "name": "partial_evaluation",
+        #[cfg(feature = "datetime")]
+        {
+            let src: serde_json::Value = json!({
+                "": {
+                    "commonTypes": {
+                        "ty": {
+                            "type": "Record",
+                            "attributes": {
+                                "a": {
+                                    "type": "Extension",
+                                    "name": "partial_evaluation",
+                                }
                             }
                         }
-                    }
-                },
-                "entityTypes": { },
-                "actions": { },
-            }
-        });
-        let schema = ValidatorSchema::from_json_value(src.clone(), Extensions::all_available());
-        assert_matches!(schema, Err(e) => {
-            expect_err(
-                &src,
-                &miette::Report::new(e),
-                &ExpectedErrorMessageBuilder::error("unknown extension type `partial_evaluation`")
-                    .help("did you mean `duration`?")
-                    .build());
-        });
+                    },
+                    "entityTypes": { },
+                    "actions": { },
+                }
+            });
+            let schema = ValidatorSchema::from_json_value(src.clone(), Extensions::all_available());
+            assert_matches!(schema, Err(e) => {
+                expect_err(
+                    &src,
+                    &miette::Report::new(e),
+                    &ExpectedErrorMessageBuilder::error("unknown extension type `partial_evaluation`")
+                        .help("did you mean `duration`?")
+                        .build());
+            });
+        }
     }
 
     #[track_caller]
diff --git a/cedar-policy-validator/src/typecheck.rs b/cedar-policy-validator/src/typecheck.rs
index e997fc0f..bed4fc4c 100644
--- a/cedar-policy-validator/src/typecheck.rs
+++ b/cedar-policy-validator/src/typecheck.rs
@@ -49,7 +49,6 @@ use cedar_policy_core::{
         PrincipalOrResourceConstraint, SlotId, Template, UnaryOp, Var,
     },
     expr_builder::ExprBuilder as _,
-    extensions::Extensions,
     parser::Loc,
 };
 
@@ -1190,6 +1189,33 @@ impl<'a> SingleEnvTypechecker<'a> {
         }
     }
 
+    // Return if `ty` is a valid comparison operator type
+    // Currently, only primitive long and certain extension types are valid
+    fn is_valid_comparison_op_type(&self, ty: &Type) -> bool {
+        match ty {
+            Type::Primitive {
+                primitive_type: Primitive::Long,
+            } => true,
+            Type::ExtensionType { name } => {
+                self.extensions.has_type_with_operator_overloading(name)
+            }
+            _ => false,
+        }
+    }
+
+    // Get all valid types satisfying `is_valid_comparison_op_type`
+    // Only used for error message construction
+    fn expected_comparison_op_types(&self) -> Vec<Type> {
+        let expected_types = self
+            .extensions
+            .types_with_operator_overloading()
+            .cloned()
+            .map(Type::extension)
+            .chain(std::iter::once(Type::primitive_long()))
+            .collect_vec();
+        expected_types
+    }
+
     /// A utility called by the main typecheck method to handle binary operator
     /// application.
     /// INVARIANT: `bin_expr` must be a `BinaryApp`
@@ -1244,11 +1270,6 @@ impl<'a> SingleEnvTypechecker<'a> {
             }
 
             BinaryOp::Less | BinaryOp::LessEq => {
-                let expected_types = Extensions::iter_type_with_operator_overloading()
-                    .cloned()
-                    .map(Type::extension)
-                    .chain(std::iter::once(Type::primitive_long()))
-                    .collect_vec();
                 let ans_arg1 = self.typecheck(prior_capability, arg1, type_errors);
                 ans_arg1.then_typecheck(|expr_ty_arg1, _| {
                     let ans_arg2 = self.typecheck(prior_capability, arg2, type_errors);
@@ -1261,13 +1282,13 @@ impl<'a> SingleEnvTypechecker<'a> {
                         match (t1, t2) {
                             (Some(Type::Never), Some(Type::Never)) => TypecheckAnswer::fail(expr),
                             (Some(Type::Never), Some(other)) => {
-                                if expected_types.contains(other) {
+                                if self.is_valid_comparison_op_type(other) {
                                     TypecheckAnswer::success(expr)
                                 } else {
                                     type_errors.push(ValidationError::expected_one_of_types(
                                         expr_ty_arg2.source_loc().cloned(),
                                         self.policy_id.clone(),
-                                        expected_types,
+                                        self.expected_comparison_op_types(),
                                         other.clone(),
                                         None,
                                     ));
@@ -1275,20 +1296,22 @@ impl<'a> SingleEnvTypechecker<'a> {
                                 }
                             }
                             (Some(other), Some(Type::Never)) => {
-                                if expected_types.contains(other) {
+                                if self.is_valid_comparison_op_type(other) {
                                     TypecheckAnswer::success(expr)
                                 } else {
                                     type_errors.push(ValidationError::expected_one_of_types(
                                         expr_ty_arg1.source_loc().cloned(),
                                         self.policy_id.clone(),
-                                        expected_types,
+                                        self.expected_comparison_op_types(),
                                         other.clone(),
                                         None,
                                     ));
                                     TypecheckAnswer::fail(expr)
                                 }
                             }
-                            (Some(t1), Some(t2)) if t1 == t2 && expected_types.contains(t1) => {
+                            (Some(t1), Some(t2))
+                                if t1 == t2 && self.is_valid_comparison_op_type(t1) =>
+                            {
                                 TypecheckAnswer::success(expr)
                             }
                             (
@@ -1321,7 +1344,7 @@ impl<'a> SingleEnvTypechecker<'a> {
                                 ));
                                 TypecheckAnswer::fail(expr)
                             }
-                            (Some(lhs), Some(rhs)) if lhs.support_operator_overloading() => {
+                            (Some(lhs), Some(rhs)) if self.is_valid_comparison_op_type(lhs) => {
                                 type_errors.push(ValidationError::expected_one_of_types(
                                     expr_ty_arg2.source_loc().cloned(),
                                     self.policy_id.clone(),
@@ -1331,7 +1354,7 @@ impl<'a> SingleEnvTypechecker<'a> {
                                 ));
                                 TypecheckAnswer::fail(expr)
                             }
-                            (Some(lhs), Some(rhs)) if rhs.support_operator_overloading() => {
+                            (Some(lhs), Some(rhs)) if self.is_valid_comparison_op_type(rhs) => {
                                 type_errors.push(ValidationError::expected_one_of_types(
                                     expr_ty_arg1.source_loc().cloned(),
                                     self.policy_id.clone(),
@@ -1342,6 +1365,7 @@ impl<'a> SingleEnvTypechecker<'a> {
                                 TypecheckAnswer::fail(expr)
                             }
                             (Some(lhs), Some(rhs)) => {
+                                let expected_types = self.expected_comparison_op_types();
                                 type_errors.push(ValidationError::expected_one_of_types(
                                     expr_ty_arg1.source_loc().cloned(),
                                     self.policy_id.clone(),
diff --git a/cedar-policy-validator/src/types.rs b/cedar-policy-validator/src/types.rs
index d8e22c4a..bbd2711d 100644
--- a/cedar-policy-validator/src/types.rs
+++ b/cedar-policy-validator/src/types.rs
@@ -671,15 +671,6 @@ impl Type {
                 | Type::EntityOrRecord(EntityRecordKind::ActionEntity { .. })
         )
     }
-
-    pub(crate) fn support_operator_overloading(&self) -> bool {
-        match self {
-            Self::ExtensionType { name } => {
-                Extensions::iter_type_with_operator_overloading().contains(name)
-            }
-            _ => false,
-        }
-    }
 }
 
 impl Display for Type {
diff --git a/cedar-policy/CHANGELOG.md b/cedar-policy/CHANGELOG.md
index 2163a3eb..9abe4091 100644
--- a/cedar-policy/CHANGELOG.md
+++ b/cedar-policy/CHANGELOG.md
@@ -15,6 +15,7 @@ Cedar Language Version: TBD
 
 ### Added
 
+- Implemented [RFC 80 (`datetime` extension)](https://github.com/strongdm/cedar-rfcs/blob/datetime-rfc/text/0080-datetime-extension.md) as an experimental feature under flag `datetime` (#1276, #1415)
 - Implemented [RFC 48 (schema annotations)](https://github.com/cedar-policy/rfcs/blob/main/text/0048-schema-annotations.md)  (#1316)
 - New `.isEmpty()` operator on sets (#1358, resolving #1356)
 - New `Entity::new_with_tags()` and `Entity::tag()` functions (#1402, resolving #1374)
diff --git a/cedar-policy/Cargo.toml b/cedar-policy/Cargo.toml
index f5c29d11..5b897107 100644
--- a/cedar-policy/Cargo.toml
+++ b/cedar-policy/Cargo.toml
@@ -41,6 +41,7 @@ default = ["ipaddr", "decimal"]
 # Cedar extensions
 ipaddr = ["cedar-policy-core/ipaddr", "cedar-policy-validator/ipaddr"]
 decimal = ["cedar-policy-core/decimal", "cedar-policy-validator/decimal"]
+datetime = ["cedar-policy-core/datetime", "cedar-policy-validator/datetime"]
 
 # Features for memory or runtime profiling
 heap-profiling = ["dep:dhat"]
@@ -48,7 +49,7 @@ corpus-timing = []
 
 # Experimental features.
 # Enable all experimental features with `cargo build --features "experimental"`
-experimental = ["partial-eval", "permissive-validate", "partial-validate", "level-validate", "entity-manifest", "protobufs"]
+experimental = ["partial-eval", "permissive-validate", "partial-validate", "level-validate", "entity-manifest", "protobufs", "datetime"]
 entity-manifest = ["cedar-policy-validator/entity-manifest"]
 partial-eval = ["cedar-policy-core/partial-eval", "cedar-policy-validator/partial-eval"]
 permissive-validate = []


Test patch:
diff --git a/cedar-policy-validator/src/typecheck/test/expr.rs b/cedar-policy-validator/src/typecheck/test/expr.rs
index d9b7960c..1f165ca9 100644
--- a/cedar-policy-validator/src/typecheck/test/expr.rs
+++ b/cedar-policy-validator/src/typecheck/test/expr.rs
@@ -21,7 +21,7 @@
 use std::{str::FromStr, vec};
 
 use cedar_policy_core::{
-    ast::{BinaryOp, EntityUID, Expr, Name, Pattern, PatternElem, SlotId, Value, Var},
+    ast::{BinaryOp, EntityUID, Expr, Pattern, PatternElem, SlotId, Var},
     est::Annotations,
     extensions::Extensions,
 };
@@ -1045,80 +1045,20 @@ fn like_typecheck_fails() {
     );
 }
 
-#[inline]
-fn get_datetime_constructor_name() -> Name {
-    "datetime".parse().unwrap()
-}
-
-#[inline]
-fn get_duration_constructor_name() -> Name {
-    "duration".parse().unwrap()
-}
-
 #[test]
 fn less_than_typechecks() {
     assert_typechecks_empty_schema(
         Expr::less(Expr::val(1), Expr::val(2)),
         Type::primitive_boolean(),
     );
-    assert_typechecks_empty_schema(
-        Expr::less(
-            Expr::call_extension_fn(
-                get_datetime_constructor_name(),
-                vec![Value::from("1970-01-01").into()],
-            ),
-            Expr::call_extension_fn(
-                get_datetime_constructor_name(),
-                vec![Value::from("1970-01-02").into()],
-            ),
-        ),
-        Type::primitive_boolean(),
-    );
-    assert_typechecks_empty_schema(
-        Expr::lesseq(
-            Expr::call_extension_fn(
-                get_datetime_constructor_name(),
-                vec![Value::from("1970-01-01").into()],
-            ),
-            Expr::call_extension_fn(
-                get_datetime_constructor_name(),
-                vec![Value::from("1970-01-02").into()],
-            ),
-        ),
-        Type::primitive_boolean(),
-    );
-    assert_typechecks_empty_schema(
-        Expr::less(
-            Expr::call_extension_fn(
-                get_duration_constructor_name(),
-                vec![Value::from("1h").into()],
-            ),
-            Expr::call_extension_fn(
-                get_duration_constructor_name(),
-                vec![Value::from("2h").into()],
-            ),
-        ),
-        Type::primitive_boolean(),
-    );
-    assert_typechecks_empty_schema(
-        Expr::lesseq(
-            Expr::call_extension_fn(
-                get_duration_constructor_name(),
-                vec![Value::from("1h").into()],
-            ),
-            Expr::call_extension_fn(
-                get_duration_constructor_name(),
-                vec![Value::from("2h").into()],
-            ),
-        ),
-        Type::primitive_boolean(),
-    );
 }
 
 #[test]
 fn less_than_typecheck_fails() {
-    let expected_types = Extensions::types_with_operator_overloading()
-        .into_iter()
+    let extensions = Extensions::all_available();
+    let expected_types = extensions
+        .types_with_operator_overloading()
+        .cloned()
         .map(Type::extension)
         .chain(std::iter::once(Type::primitive_long()))
         .collect_vec();
@@ -1181,73 +1121,6 @@ fn less_than_typecheck_fails() {
             None,
         )],
     );
-
-    let src = r#"true < duration("1h")"#;
-    let errors =
-        assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
-    assert_sets_equal(
-        errors,
-        [ValidationError::expected_type(
-            get_loc(src, "true"),
-            expr_id_placeholder(),
-            Type::ExtensionType {
-                name: get_duration_constructor_name(),
-            },
-            Type::singleton_boolean(true),
-            None,
-        )],
-    );
-
-    // Error reporting favors long
-    let src = r#"duration("1d") < 1"#;
-    let errors =
-        assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
-    assert_sets_equal(
-        errors,
-        [ValidationError::expected_type(
-            get_loc(src, r#"duration("1d")"#),
-            expr_id_placeholder(),
-            Type::primitive_long(),
-            Type::ExtensionType {
-                name: get_duration_constructor_name(),
-            },
-            None,
-        )],
-    );
-
-    let src = r#"1 < duration("1d")"#;
-    let errors =
-        assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
-    assert_sets_equal(
-        errors,
-        [ValidationError::expected_type(
-            get_loc(src, r#"duration("1d")"#),
-            expr_id_placeholder(),
-            Type::primitive_long(),
-            Type::ExtensionType {
-                name: get_duration_constructor_name(),
-            },
-            None,
-        )],
-    );
-
-    let src = r#"datetime("1970-01-01") < duration("1d")"#;
-    let errors =
-        assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
-    assert_sets_equal(
-        errors,
-        [ValidationError::expected_type(
-            get_loc(src, r#"duration("1d")"#),
-            expr_id_placeholder(),
-            Type::ExtensionType {
-                name: get_datetime_constructor_name(),
-            },
-            Type::ExtensionType {
-                name: get_duration_constructor_name(),
-            },
-            None,
-        )],
-    );
 }
 
 #[test]
@@ -1476,3 +1349,229 @@ fn is_typechecks() {
         Type::singleton_boolean(false),
     );
 }
+
+#[cfg(feature = "datetime")]
+mod datetime {
+    use cedar_policy_core::{
+        ast::{Expr, Name, Value},
+        extensions::Extensions,
+    };
+    use itertools::Itertools;
+
+    use crate::{
+        typecheck::test::test_utils::{expr_id_placeholder, get_loc},
+        types::Type,
+        ValidationError,
+    };
+
+    use super::{
+        assert_sets_equal, assert_typecheck_fails_empty_schema, assert_typechecks_empty_schema,
+    };
+
+    #[inline]
+    fn get_datetime_constructor_name() -> Name {
+        "datetime".parse().unwrap()
+    }
+
+    #[inline]
+    fn get_duration_constructor_name() -> Name {
+        "duration".parse().unwrap()
+    }
+
+    #[test]
+    fn less_than_typechecks() {
+        assert_typechecks_empty_schema(
+            Expr::less(Expr::val(1), Expr::val(2)),
+            Type::primitive_boolean(),
+        );
+        assert_typechecks_empty_schema(
+            Expr::less(
+                Expr::call_extension_fn(
+                    get_datetime_constructor_name(),
+                    vec![Value::from("1970-01-01").into()],
+                ),
+                Expr::call_extension_fn(
+                    get_datetime_constructor_name(),
+                    vec![Value::from("1970-01-02").into()],
+                ),
+            ),
+            Type::primitive_boolean(),
+        );
+        assert_typechecks_empty_schema(
+            Expr::lesseq(
+                Expr::call_extension_fn(
+                    get_datetime_constructor_name(),
+                    vec![Value::from("1970-01-01").into()],
+                ),
+                Expr::call_extension_fn(
+                    get_datetime_constructor_name(),
+                    vec![Value::from("1970-01-02").into()],
+                ),
+            ),
+            Type::primitive_boolean(),
+        );
+        assert_typechecks_empty_schema(
+            Expr::less(
+                Expr::call_extension_fn(
+                    get_duration_constructor_name(),
+                    vec![Value::from("1h").into()],
+                ),
+                Expr::call_extension_fn(
+                    get_duration_constructor_name(),
+                    vec![Value::from("2h").into()],
+                ),
+            ),
+            Type::primitive_boolean(),
+        );
+        assert_typechecks_empty_schema(
+            Expr::lesseq(
+                Expr::call_extension_fn(
+                    get_duration_constructor_name(),
+                    vec![Value::from("1h").into()],
+                ),
+                Expr::call_extension_fn(
+                    get_duration_constructor_name(),
+                    vec![Value::from("2h").into()],
+                ),
+            ),
+            Type::primitive_boolean(),
+        );
+    }
+
+    #[test]
+    fn less_than_typecheck_fails() {
+        let extensions = Extensions::all_available();
+        let expected_types = extensions
+            .types_with_operator_overloading()
+            .cloned()
+            .map(Type::extension)
+            .chain(std::iter::once(Type::primitive_long()))
+            .collect_vec();
+        let src = "true < false";
+        let errors =
+            assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
+        assert_sets_equal(
+            errors,
+            [
+                ValidationError::expected_one_of_types(
+                    get_loc(src, "true"),
+                    expr_id_placeholder(),
+                    expected_types.clone(),
+                    Type::singleton_boolean(true),
+                    None,
+                ),
+                ValidationError::expected_one_of_types(
+                    get_loc(src, "false"),
+                    expr_id_placeholder(),
+                    expected_types.clone(),
+                    Type::singleton_boolean(false),
+                    None,
+                ),
+            ],
+        );
+
+        let src = "true < \"\"";
+        let errors =
+            assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
+        assert_sets_equal(
+            errors,
+            [
+                ValidationError::expected_one_of_types(
+                    get_loc(src, "true"),
+                    expr_id_placeholder(),
+                    expected_types.clone(),
+                    Type::singleton_boolean(true),
+                    None,
+                ),
+                ValidationError::expected_one_of_types(
+                    get_loc(src, "\"\""),
+                    expr_id_placeholder(),
+                    expected_types,
+                    Type::primitive_string(),
+                    None,
+                ),
+            ],
+        );
+
+        let src = "true < 1";
+        let errors =
+            assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
+        assert_sets_equal(
+            errors,
+            [ValidationError::expected_type(
+                get_loc(src, "true"),
+                expr_id_placeholder(),
+                Type::primitive_long(),
+                Type::singleton_boolean(true),
+                None,
+            )],
+        );
+
+        let src = r#"true < duration("1h")"#;
+        let errors =
+            assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
+        assert_sets_equal(
+            errors,
+            [ValidationError::expected_type(
+                get_loc(src, "true"),
+                expr_id_placeholder(),
+                Type::ExtensionType {
+                    name: get_duration_constructor_name(),
+                },
+                Type::singleton_boolean(true),
+                None,
+            )],
+        );
+
+        // Error reporting favors long
+        let src = r#"duration("1d") < 1"#;
+        let errors =
+            assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
+        assert_sets_equal(
+            errors,
+            [ValidationError::expected_type(
+                get_loc(src, r#"duration("1d")"#),
+                expr_id_placeholder(),
+                Type::primitive_long(),
+                Type::ExtensionType {
+                    name: get_duration_constructor_name(),
+                },
+                None,
+            )],
+        );
+
+        let src = r#"1 < duration("1d")"#;
+        let errors =
+            assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
+        assert_sets_equal(
+            errors,
+            [ValidationError::expected_type(
+                get_loc(src, r#"duration("1d")"#),
+                expr_id_placeholder(),
+                Type::primitive_long(),
+                Type::ExtensionType {
+                    name: get_duration_constructor_name(),
+                },
+                None,
+            )],
+        );
+
+        let src = r#"datetime("1970-01-01") < duration("1d")"#;
+        let errors =
+            assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
+        assert_sets_equal(
+            errors,
+            [ValidationError::expected_type(
+                get_loc(src, r#"duration("1d")"#),
+                expr_id_placeholder(),
+                Type::ExtensionType {
+                    name: get_datetime_constructor_name(),
+                },
+                Type::ExtensionType {
+                    name: get_duration_constructor_name(),
+                },
+                None,
+            )],
+        );
+    }
+}
diff --git a/cedar-policy-validator/src/typecheck/test/partial.rs b/cedar-policy-validator/src/typecheck/test/partial.rs
index aa1f896c..84d7cc69 100644
--- a/cedar-policy-validator/src/typecheck/test/partial.rs
+++ b/cedar-policy-validator/src/typecheck/test/partial.rs
@@ -398,15 +398,16 @@ mod fails_empty_schema {
         // We expect to see a type error for the incorrect literal argument to
         // various operators. No error should be generated for missing
         // attributes or the type of the attributes.
-
+        let extensions = Extensions::all_available();
         let src = r#"permit(principal, action, resource) when { principal.foo > "a" };"#;
         assert_typecheck_fails_empty_schema(
             parse_policy(None, src).unwrap(),
             [ValidationError::expected_one_of_types(
                 get_loc(src, r#""a""#),
                 PolicyID::from_string("policy0"),
-                Extensions::types_with_operator_overloading()
-                    .into_iter()
+                extensions
+                    .types_with_operator_overloading()
+                    .cloned()
                     .map(Type::extension)
                     .chain(std::iter::once(Type::primitive_long()))
                     .collect(),
@@ -645,6 +646,7 @@ mod fail_partial_schema {
 
     #[test]
     fn error_on_declared_attr() {
+        let extensions = Extensions::all_available();
         // `name` is declared as a `String` in the partial schema, so we can
         // error even though `principal.unknown` is not declared.
         let src = r#"permit(principal == User::"alice", action, resource) when { principal.name > principal.unknown };"#;
@@ -653,8 +655,9 @@ mod fail_partial_schema {
             [ValidationError::expected_one_of_types(
                 get_loc(src, "principal.name"),
                 PolicyID::from_string("policy0"),
-                Extensions::types_with_operator_overloading()
-                    .into_iter()
+                extensions
+                    .types_with_operator_overloading()
+                    .cloned()
                     .map(Type::extension)
                     .chain(std::iter::once(Type::primitive_long()))
                     .collect(),