Repository: cedar-policy/cedar
Pull request #1276 (2024-10-16 17:05:59)

`datetime` extension
## Description of changes

Implement [RFC 80](https://github.com/strongdm/cedar-rfcs/blob/datetime-rfc/text/0080-datetime-extension.md)

## 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] 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.)

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`.)



Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 80b3d7b7..7e3a78c0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -98,9 +98,9 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.92"
+version = "1.0.93"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74f37166d7d48a0284b99dd824694c26119c700b53bf0d1540cdb147dbdaaf13"
+checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775"
 
 [[package]]
 name = "arbitrary"
@@ -319,7 +319,7 @@ dependencies = [
  "serde_json",
  "serde_with",
  "smol_str",
- "thiserror",
+ "thiserror 2.0.3",
  "tsify",
  "wasm-bindgen",
 ]
@@ -340,7 +340,7 @@ dependencies = [
  "serde",
  "serde_json",
  "tempfile",
- "thiserror",
+ "thiserror 2.0.3",
 ]
 
 [[package]]
@@ -348,6 +348,7 @@ name = "cedar-policy-core"
 version = "4.1.0"
 dependencies = [
  "arbitrary",
+ "chrono",
  "cool_asserts",
  "either",
  "itertools 0.13.0",
@@ -367,7 +368,7 @@ dependencies = [
  "serde_with",
  "smol_str",
  "stacker",
- "thiserror",
+ "thiserror 2.0.3",
  "tsify",
  "wasm-bindgen",
 ]
@@ -410,7 +411,7 @@ dependencies = [
  "similar-asserts",
  "smol_str",
  "stacker",
- "thiserror",
+ "thiserror 2.0.3",
  "tsify",
  "unicode-security",
  "wasm-bindgen",
@@ -472,8 +473,10 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
 dependencies = [
  "android-tzdata",
  "iana-time-zone",
+ "js-sys",
  "num-traits",
  "serde",
+ "wasm-bindgen",
  "windows-targets 0.52.6",
 ]
 
@@ -1323,7 +1326,7 @@ dependencies = [
  "supports-unicode",
  "terminal_size",
  "textwrap",
- "thiserror",
+ "thiserror 1.0.67",
  "unicode-width",
 ]
 
@@ -2138,7 +2141,7 @@ dependencies = [
  "cfg-if",
  "libc",
  "psm",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
 ]
 
 [[package]]
@@ -2248,7 +2251,16 @@ version = "1.0.67"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3b3c6efbfc763e64eb85c11c25320f0737cb7364c4b6336db90aa9ebe27a0bbd"
 dependencies = [
- "thiserror-impl",
+ "thiserror-impl 1.0.67",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa"
+dependencies = [
+ "thiserror-impl 2.0.3",
 ]
 
 [[package]]
@@ -2262,6 +2274,17 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "thiserror-impl"
+version = "2.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "thousands"
 version = "0.2.0"
@@ -2619,7 +2642,7 @@ version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
 dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.59.0",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 7789ffc7..3228fce7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,10 @@ resolver = "2"
 [profile.release]
 overflow-checks = true
 
+[profile.bench]
+overflow-checks = true
+debug = "line-tables-only"  # this adds more debug symbols/info to the binary than the default for `release` (which is `none`)
+
 # Keys that packages can inherit
 [workspace.package]
 # Check the minimum supported Rust version with `cargo install cargo-msrv && cargo msrv --min 1.X.0` where `X` is something lower than the version noted here (to confirm that versions lower than the one noted here _don't_ work)
diff --git a/cedar-policy-cli/Cargo.toml b/cedar-policy-cli/Cargo.toml
index 80999438..3f234e32 100644
--- a/cedar-policy-cli/Cargo.toml
+++ b/cedar-policy-cli/Cargo.toml
@@ -17,7 +17,7 @@ clap = { version = "4", features = ["derive", "env"] }
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 miette = { version = "7.1.0", features = ["fancy"] }
-thiserror = "1.0"
+thiserror = "2.0"
 semver = "1.0.23"
 
 [features]
diff --git a/cedar-policy-core/Cargo.toml b/cedar-policy-core/Cargo.toml
index c95bcf8d..22a9bd15 100644
--- a/cedar-policy-core/Cargo.toml
+++ b/cedar-policy-core/Cargo.toml
@@ -7,7 +7,7 @@ build = "build.rs"
 version.workspace = true
 license.workspace = true
 categories.workspace = true
-description = "Core implemenation of the Cedar Policy language."
+description = "Core implementation of the Cedar policy language"
 keywords.workspace = true
 homepage.workspace = true
 repository.workspace = true
@@ -22,7 +22,7 @@ either = "1.8"
 itertools = "0.13"
 ref-cast = "1.0"
 rustc_lexer = "0.1"
-thiserror = "1.0"
+thiserror = "2.0"
 smol_str = { version = "0.3", features = ["serde"] }
 stacker = "0.1.15"
 arbitrary = { version = "1", features = ["derive"], optional = true }
@@ -37,14 +37,18 @@ serde-wasm-bindgen = { version = "0.6", optional = true }
 tsify = { version = "0.4.5", optional = true }
 wasm-bindgen = { version = "0.2.82", optional = true }
 
+# datetime extension requires chrono
+chrono = { version = "0.4.38", optional = true, default-features = false}
+
 # protobuf dependency
 prost = { version = "0.13.3", optional = true }
 
 [features]
 # by default, enable all Cedar extensions
-default = ["ipaddr", "decimal"]
+default = ["ipaddr", "decimal", "datetime"]
 ipaddr = []
 decimal = ["dep:regex"]
+datetime = ["dep:chrono", "dep:regex"]
 
 # Enables `Arbitrary` implementations for several types in this crate
 arbitrary = ["dep:arbitrary"]
diff --git a/cedar-policy-core/src/ast/entity.rs b/cedar-policy-core/src/ast/entity.rs
index d8970818..38a9ee40 100644
--- a/cedar-policy-core/src/ast/entity.rs
+++ b/cedar-policy-core/src/ast/entity.rs
@@ -30,6 +30,7 @@ use serde_with::{serde_as, TryFromInto};
 use smol_str::SmolStr;
 use std::collections::{BTreeMap, HashMap, HashSet};
 use std::str::FromStr;
+use std::sync::Arc;
 use thiserror::Error;
 
 /// The entity type that Actions must have
@@ -657,6 +658,25 @@ impl TCNode<EntityUID> for Entity {
     }
 }
 
+impl TCNode<EntityUID> for Arc<Entity> {
+    fn get_key(&self) -> EntityUID {
+        self.uid().clone()
+    }
+
+    fn add_edge_to(&mut self, k: EntityUID) {
+        // Use Arc::make_mut to get a mutable reference to the inner value
+        Arc::make_mut(self).add_ancestor(k)
+    }
+
+    fn out_edges(&self) -> Box<dyn Iterator<Item = &EntityUID> + '_> {
+        Box::new(self.ancestors())
+    }
+
+    fn has_edge_to(&self, e: &EntityUID) -> bool {
+        self.is_descendant_of(e)
+    }
+}
+
 impl std::fmt::Display for Entity {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         write!(
@@ -753,6 +773,13 @@ impl From<&Entity> for proto::Entity {
     }
 }
 
+#[cfg(feature = "protobufs")]
+impl From<&Arc<Entity>> for proto::Entity {
+    fn from(v: &Arc<Entity>) -> Self {
+        Self::from(v.as_ref())
+    }
+}
+
 /// `PartialValue`, but serialized as a `RestrictedExpr`.
 ///
 /// (Extension values can't be directly serialized, but can be serialized as
diff --git a/cedar-policy-core/src/ast/expr.rs b/cedar-policy-core/src/ast/expr.rs
index 3699d428..c50ae0ec 100644
--- a/cedar-policy-core/src/ast/expr.rs
+++ b/cedar-policy-core/src/ast/expr.rs
@@ -171,7 +171,7 @@ impl From<ValueKind> for Expr {
                     .map(|(k, v)| (k, Expr::from(v))),
             )
             .expect("cannot have duplicate key because the input was already a BTreeMap"),
-            ValueKind::ExtensionValue(ev) => Expr::from(ev.as_ref().clone()),
+            ValueKind::ExtensionValue(ev) => RestrictedExpr::from(ev.as_ref().clone()).into(),
         }
     }
 }
diff --git a/cedar-policy-core/src/ast/extension.rs b/cedar-policy-core/src/ast/extension.rs
index 7557c434..d8b8e05e 100644
--- a/cedar-policy-core/src/ast/extension.rs
+++ b/cedar-policy-core/src/ast/extension.rs
@@ -14,15 +14,34 @@
  * 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::fmt::{Debug, Display};
+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
+        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,
@@ -343,12 +362,15 @@ impl std::fmt::Debug for ExtensionFunction {
 /// Anything implementing this trait can be used as a first-class value in
 /// Cedar. For instance, the `ipaddr` extension uses this mechanism
 /// to implement IPAddr as a Cedar first-class value.
-pub trait ExtensionValue: Debug + Display + Send + Sync + UnwindSafe + RefUnwindSafe {
+pub trait ExtensionValue: Debug + Send + Sync + UnwindSafe + RefUnwindSafe {
     /// Get the name of the type of this value.
     ///
     /// Cedar has nominal typing, so two values have the same type iff they
     /// return the same typename here.
     fn typename(&self) -> Name;
+
+    /// If it supports operator overloading
+    fn supports_operator_overloading(&self) -> bool;
 }
 
 impl<V: ExtensionValue> StaticallyTyped for V {
@@ -360,30 +382,26 @@ impl<V: ExtensionValue> StaticallyTyped for V {
 }
 
 #[derive(Debug, Clone)]
-/// Object container for extension values, also stores the constructor-and-args
-/// that can reproduce the value (important for converting the value back to
-/// `RestrictedExpr` for instance)
-pub struct ExtensionValueWithArgs {
-    value: Arc<dyn InternalExtensionValue>,
-    pub(crate) constructor: Name,
-    /// Args are stored in `RestrictedExpr` form, just because that's most
-    /// convenient for reconstructing a `RestrictedExpr` that reproduces this
-    /// extension value
+/// Object container for extension values
+/// An extension value must be representable by a [`RestrictedExpr`]
+/// Specifically, it will be a function call `func` on `args`
+/// Note that `func` may not be the constructor. A counterexample is that a
+/// `datetime` is represented by an `offset` method call.
+/// Nevertheless, an invariant is that `eval(<func>(<args>)) == value`
+pub struct RepresentableExtensionValue {
+    pub(crate) func: Name,
     pub(crate) args: Vec<RestrictedExpr>,
+    pub(crate) value: Arc<dyn InternalExtensionValue>,
 }
 
-impl ExtensionValueWithArgs {
-    /// Create a new `ExtensionValueWithArgs`
+impl RepresentableExtensionValue {
+    /// Create a new [`RepresentableExtensionValue`]
     pub fn new(
         value: Arc<dyn InternalExtensionValue + Send + Sync>,
-        constructor: Name,
+        func: Name,
         args: Vec<RestrictedExpr>,
     ) -> Self {
-        Self {
-            value,
-            constructor,
-            args,
-        }
+        Self { value, func, args }
     }
 
     /// Get the internal value
@@ -396,46 +414,40 @@ impl ExtensionValueWithArgs {
         self.value.typename()
     }
 
-    /// Get the constructor and args that can reproduce this value
-    pub fn constructor_and_args(&self) -> (&Name, &[RestrictedExpr]) {
-        (&self.constructor, &self.args)
+    /// If this value supports operator overloading
+    pub(crate) fn supports_operator_overloading(&self) -> bool {
+        self.value.supports_operator_overloading()
     }
 }
 
-impl From<ExtensionValueWithArgs> for Expr {
-    fn from(val: ExtensionValueWithArgs) -> Self {
-        ExprBuilder::new().call_extension_fn(val.constructor, val.args.into_iter().map(Into::into))
+impl From<RepresentableExtensionValue> for RestrictedExpr {
+    fn from(val: RepresentableExtensionValue) -> Self {
+        RestrictedExpr::call_extension_fn(val.func, val.args)
     }
 }
 
-impl StaticallyTyped for ExtensionValueWithArgs {
+impl StaticallyTyped for RepresentableExtensionValue {
     fn type_of(&self) -> Type {
         self.value.type_of()
     }
 }
 
-impl Display for ExtensionValueWithArgs {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{}", self.value)
-    }
-}
-
-impl PartialEq for ExtensionValueWithArgs {
+impl PartialEq for RepresentableExtensionValue {
     fn eq(&self, other: &Self) -> bool {
         // Values that are equal are equal regardless of which arguments made them
         self.value.as_ref() == other.value.as_ref()
     }
 }
 
-impl Eq for ExtensionValueWithArgs {}
+impl Eq for RepresentableExtensionValue {}
 
-impl PartialOrd for ExtensionValueWithArgs {
+impl PartialOrd for RepresentableExtensionValue {
     fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
         Some(self.cmp(other))
     }
 }
 
-impl Ord for ExtensionValueWithArgs {
+impl Ord for RepresentableExtensionValue {
     fn cmp(&self, other: &Self) -> std::cmp::Ordering {
         self.value.cmp(&other.value)
     }
@@ -464,7 +476,7 @@ pub trait InternalExtensionValue: ExtensionValue {
     fn cmp_extvalue(&self, other: &dyn InternalExtensionValue) -> std::cmp::Ordering;
 }
 
-impl<V: 'static + Eq + Ord + ExtensionValue + Send + Sync> InternalExtensionValue for V {
+impl<V: 'static + Eq + Ord + ExtensionValue + Send + Sync + Clone> InternalExtensionValue for V {
     fn as_any(&self) -> &dyn Any {
         self
     }
diff --git a/cedar-policy-core/src/ast/policy.rs b/cedar-policy-core/src/ast/policy.rs
index 057ec93c..e178f49e 100644
--- a/cedar-policy-core/src/ast/policy.rs
+++ b/cedar-policy-core/src/ast/policy.rs
@@ -303,7 +303,7 @@ impl std::fmt::Display for Template {
 pub enum LinkingError {
     /// An error with the slot arguments provided
     // INVARIANT: `unbound_values` and `extra_values` can't both be empty
-    #[error("{}", describe_arity_error(.unbound_values, .extra_values))]
+    #[error(fmt = describe_arity_error)]
     ArityError {
         /// Error for when some Slots were not provided values
         unbound_values: Vec<SlotId>,
@@ -338,14 +338,18 @@ impl LinkingError {
     }
 }
 
-fn describe_arity_error(unbound_values: &[SlotId], extra_values: &[SlotId]) -> String {
+fn describe_arity_error(
+    unbound_values: &[SlotId],
+    extra_values: &[SlotId],
+    fmt: &mut std::fmt::Formatter<'_>,
+) -> std::fmt::Result {
     match (unbound_values.len(), extra_values.len()) {
         // PANIC SAFETY 0,0 case is not an error
         #[allow(clippy::unreachable)]
         (0,0) => unreachable!(),
-        (_unbound, 0) => format!("the following slots were not provided as arguments: {}", unbound_values.iter().join(",")),
-        (0, _extra) => format!("the following slots were provided as arguments, but did not exist in the template: {}", extra_values.iter().join(",")),
-        (_unbound, _extra) => format!("the following slots were not provided as arguments: {}. The following slots were provided as arguments, but did not exist in the template: {}", unbound_values.iter().join(","), extra_values.iter().join(","))
+        (_unbound, 0) => write!(fmt, "the following slots were not provided as arguments: {}", unbound_values.iter().join(",")),
+        (0, _extra) => write!(fmt, "the following slots were provided as arguments, but did not exist in the template: {}", extra_values.iter().join(",")),
+        (_unbound, _extra) => write!(fmt, "the following slots were not provided as arguments: {}. The following slots were provided as arguments, but did not exist in the template: {}", unbound_values.iter().join(","), extra_values.iter().join(",")),
     }
 }
 
diff --git a/cedar-policy-core/src/ast/restricted_expr.rs b/cedar-policy-core/src/ast/restricted_expr.rs
index 6a60423a..9fc5a06a 100644
--- a/cedar-policy-core/src/ast/restricted_expr.rs
+++ b/cedar-policy-core/src/ast/restricted_expr.rs
@@ -263,7 +263,7 @@ impl From<ValueKind> for RestrictedExpr {
             .expect("can't have duplicate keys, because the input `map` was already a BTreeMap"),
             ValueKind::ExtensionValue(ev) => {
                 let ev = Arc::unwrap_or_clone(ev);
-                RestrictedExpr::call_extension_fn(ev.constructor, ev.args)
+                ev.into()
             }
         }
     }
diff --git a/cedar-policy-core/src/ast/value.rs b/cedar-policy-core/src/ast/value.rs
index 7f81c4e2..fe43f86b 100644
--- a/cedar-policy-core/src/ast/value.rs
+++ b/cedar-policy-core/src/ast/value.rs
@@ -50,7 +50,7 @@ pub enum ValueKind {
     /// Evaluating an `Expr` can result in a first-class anonymous record (keyed on String)
     Record(Arc<BTreeMap<SmolStr, Value>>),
     /// Evaluating an `Expr` can result in an extension value
-    ExtensionValue(Arc<ExtensionValueWithArgs>),
+    ExtensionValue(Arc<RepresentableExtensionValue>),
 }
 
 // Custom impl of `Ord`, ignoring the `Loc`s
@@ -517,7 +517,7 @@ impl std::fmt::Display for ValueKind {
             Self::Record(record) => {
                 write!(f, "<first-class record with {} fields>", record.len())
             }
-            Self::ExtensionValue(ev) => write!(f, "{}", ev),
+            Self::ExtensionValue(ev) => write!(f, "{}", RestrictedExpr::from(ev.as_ref().clone())),
         }
     }
 }
diff --git a/cedar-policy-core/src/entities.rs b/cedar-policy-core/src/entities.rs
index 2385f6e6..830bdaad 100644
--- a/cedar-policy-core/src/entities.rs
+++ b/cedar-policy-core/src/entities.rs
@@ -58,7 +58,7 @@ pub struct Entities {
     /// Important internal invariant: for any `Entities` object that exists, the
     /// the `ancestor` relation is transitively closed.
     #[serde_as(as = "Vec<(_, _)>")]
-    entities: HashMap<EntityUID, Entity>,
+    entities: HashMap<EntityUID, Arc<Entity>>,
 
     /// The mode flag determines whether this store functions as a partial store or
     /// as a fully concrete store.
@@ -109,7 +109,7 @@ impl Entities {
 
     /// Iterate over the `Entity`s in the `Entities`
     pub fn iter(&self) -> impl Iterator<Item = &Entity> {
-        self.entities.values()
+        self.entities.values().map(|e| e.as_ref())
     }
 
     /// Adds the [`crate::ast::Entity`]s in the iterator to this [`Entities`].
@@ -125,7 +125,7 @@ impl Entities {
     /// responsible for ensuring that TC and DAG hold before calling this method.
     pub fn add_entities(
         mut self,
-        collection: impl IntoIterator<Item = Entity>,
+        collection: impl IntoIterator<Item = Arc<Entity>>,
         schema: Option<&impl Schema>,
         tc_computation: TCComputation,
         extensions: &Extensions<'_>,
@@ -174,7 +174,7 @@ impl Entities {
         tc_computation: TCComputation,
         extensions: &Extensions<'_>,
     ) -> Result<Self> {
-        let mut entity_map = create_entity_map(entities.into_iter())?;
+        let mut entity_map = create_entity_map(entities.into_iter().map(Arc::new))?;
         if let Some(schema) = schema {
             // Validate non-action entities against schema.
             // We do this before adding the actions, because we trust the
@@ -213,7 +213,7 @@ impl Entities {
                 schema
                     .action_entities()
                     .into_iter()
-                    .map(|e| (e.uid().clone(), Arc::unwrap_or_clone(e))),
+                    .map(|e: Arc<Entity>| (e.uid().clone(), e)),
             );
         }
         Ok(Self {
@@ -252,6 +252,7 @@ impl Entities {
     fn to_ejsons(&self) -> Result<Vec<EntityJson>> {
         self.entities
             .values()
+            .map(Arc::as_ref)
             .map(EntityJson::from_entity)
             .collect::<std::result::Result<_, JsonSerializationError>>()
             .map_err(Into::into)
@@ -322,7 +323,9 @@ impl Entities {
 }
 
 /// Create a map from EntityUids to Entities, erroring if there are any duplicates
-fn create_entity_map(es: impl Iterator<Item = Entity>) -> Result<HashMap<EntityUID, Entity>> {
+fn create_entity_map(
+    es: impl Iterator<Item = Arc<Entity>>,
+) -> Result<HashMap<EntityUID, Arc<Entity>>> {
     let mut map = HashMap::new();
     for e in es {
         match map.entry(e.uid().clone()) {
@@ -338,10 +341,13 @@ fn create_entity_map(es: impl Iterator<Item = Entity>) -> Result<HashMap<EntityU
 impl IntoIterator for Entities {
     type Item = Entity;
 
-    type IntoIter = hash_map::IntoValues<EntityUID, Entity>;
+    type IntoIter = std::iter::Map<
+        std::collections::hash_map::IntoValues<EntityUID, Arc<Entity>>,
+        fn(Arc<Entity>) -> Entity,
+    >;
 
     fn into_iter(self) -> Self::IntoIter {
-        self.entities.into_values()
+        self.entities.into_values().map(Arc::unwrap_or_clone)
     }
 }
 
@@ -363,7 +369,11 @@ impl From<&proto::Entities> for Entities {
     // PANIC SAFETY: experimental feature
     #[allow(clippy::expect_used)]
     fn from(v: &proto::Entities) -> Self {
-        let entities: Vec<Entity> = v.entities.iter().map(Entity::from).collect();
+        let entities: Vec<Arc<Entity>> = v
+            .entities
+            .iter()
+            .map(|e| Arc::new(Entity::from(e)))
+            .collect();
 
         #[cfg(not(feature = "partial-eval"))]
         let result = Entities::new();
@@ -548,7 +558,8 @@ mod json_parsing_tests {
 
         let addl_entities = parser
             .iter_from_json_value(new)
-            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)));
+            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)))
+            .map(Arc::new);
         let err = simple_entities(&parser).add_entities(
             addl_entities,
             None::<&NoEntitiesSchema>,
@@ -588,7 +599,8 @@ mod json_parsing_tests {
 
         let addl_entities = parser
             .iter_from_json_value(new)
-            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)));
+            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)))
+            .map(Arc::new);
         let err = simple_entities(&parser).add_entities(
             addl_entities,
             None::<&NoEntitiesSchema>,
@@ -627,7 +639,8 @@ mod json_parsing_tests {
 
         let addl_entities = parser
             .iter_from_json_value(new)
-            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)));
+            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)))
+            .map(Arc::new);
         let err = simple_entities(&parser).add_entities(
             addl_entities,
             None::<&NoEntitiesSchema>,
@@ -670,7 +683,8 @@ mod json_parsing_tests {
 
         let addl_entities = parser
             .iter_from_json_value(new)
-            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)));
+            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)))
+            .map(Arc::new);
         let es = simple_entities(&parser)
             .add_entities(
                 addl_entities,
@@ -709,7 +723,8 @@ mod json_parsing_tests {
 
         let addl_entities = parser
             .iter_from_json_value(new)
-            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)));
+            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)))
+            .map(Arc::new);
         let es = simple_entities(&parser)
             .add_entities(
                 addl_entities,
@@ -750,7 +765,8 @@ mod json_parsing_tests {
 
         let addl_entities = parser
             .iter_from_json_value(new)
-            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)));
+            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)))
+            .map(Arc::new);
         let es = simple_entities(&parser)
             .add_entities(
                 addl_entities,
@@ -790,7 +806,8 @@ mod json_parsing_tests {
 
         let addl_entities = parser
             .iter_from_json_value(new)
-            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)));
+            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)))
+            .map(Arc::new);
         let es = simple_entities(&parser)
             .add_entities(
                 addl_entities,
@@ -817,7 +834,8 @@ mod json_parsing_tests {
 
         let addl_entities = parser
             .iter_from_json_value(new)
-            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)));
+            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)))
+            .map(Arc::new);
         let err = simple_entities(&parser)
             .add_entities(
                 addl_entities,
@@ -838,7 +856,8 @@ mod json_parsing_tests {
         let new = serde_json::json!([{"uid":{ "type": "Test", "id": "alice" }, "attrs" : {}, "parents" : []}]);
         let addl_entities = parser
             .iter_from_json_value(new)
-            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)));
+            .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e)))
+            .map(Arc::new);
         let err = simple_entities(&parser).add_entities(
             addl_entities,
             None::<&NoEntitiesSchema>,
@@ -3531,14 +3550,16 @@ pub mod protobuf_tests {
         let attrs = (1..=7)
             .map(|id| (format!("{id}").into(), RestrictedExpr::val(true)))
             .collect::<HashMap<SmolStr, _>>();
-        let entity: Entity = Entity::new(
-            r#"Foo::"bar""#.parse().unwrap(),
-            attrs.clone(),
-            HashSet::new(),
-            BTreeMap::new(),
-            &Extensions::none(),
-        )
-        .unwrap();
+        let entity: Arc<Entity> = Arc::new(
+            Entity::new(
+                r#"Foo::"bar""#.parse().unwrap(),
+                attrs.clone(),
+                HashSet::new(),
+                BTreeMap::new(),
+                &Extensions::none(),
+            )
+            .unwrap(),
+        );
         let mut entities2: Entities = Entities::new();
         entities2 = entities2
             .add_entities(
@@ -3554,14 +3575,16 @@ pub mod protobuf_tests {
         );
 
         // Two Element Test
-        let entity2: Entity = Entity::new(
-            r#"Bar::"foo""#.parse().unwrap(),
-            attrs.clone(),
-            HashSet::new(),
-            BTreeMap::new(),
-            &Extensions::none(),
-        )
-        .unwrap();
+        let entity2: Arc<Entity> = Arc::new(
+            Entity::new(
+                r#"Bar::"foo""#.parse().unwrap(),
+                attrs.clone(),
+                HashSet::new(),
+                BTreeMap::new(),
+                &Extensions::none(),
+            )
+            .unwrap(),
+        );
         let mut entities3: Entities = Entities::new();
         entities3 = entities3
             .add_entities(
diff --git a/cedar-policy-core/src/entities/json/value.rs b/cedar-policy-core/src/entities/json/value.rs
index 2e02aad1..119dc01d 100644
--- a/cedar-policy-core/src/entities/json/value.rs
+++ b/cedar-policy-core/src/entities/json/value.rs
@@ -35,7 +35,7 @@ use either::Either;
 use serde::{Deserialize, Serialize};
 use serde_with::serde_as;
 use serde_with::{DeserializeAs, SerializeAs};
-use smol_str::SmolStr;
+use smol_str::{SmolStr, ToSmolStr};
 use std::collections::{BTreeMap, HashSet};
 use std::sync::Arc;
 
@@ -359,16 +359,18 @@ impl CedarValueJson {
                 ))
             }
             ValueKind::ExtensionValue(ev) => {
-                let ext_fn: &Name = &ev.constructor;
+                let ext_func = &ev.func;
                 Ok(Self::ExtnEscape {
                     __extn: FnAndArg {
-                        ext_fn: ext_fn.to_string().into(),
+                        ext_fn: ext_func.to_smolstr(),
                         arg: match ev.args.as_slice() {
                             [ref expr] => Box::new(Self::from_expr(expr.as_borrowed())?),
-                            [] => return Err(JsonSerializationError::call_0_args(ext_fn.clone())),
+                            [] => {
+                                return Err(JsonSerializationError::call_0_args(ext_func.clone()))
+                            }
                             _ => {
                                 return Err(JsonSerializationError::call_2_or_more_args(
-                                    ext_fn.clone(),
+                                    ext_func.clone(),
                                 ))
                             }
                         },
diff --git a/cedar-policy-core/src/est.rs b/cedar-policy-core/src/est.rs
index 87c8f135..ed1453b0 100644
--- a/cedar-policy-core/src/est.rs
+++ b/cedar-policy-core/src/est.rs
@@ -3403,9 +3403,50 @@ mod test {
                 ]
             }
         );
-        let est: Policy = serde_json::from_value(bad).unwrap();
-        let ast: Result<ast::Policy, _> = est.try_into_ast_policy(None);
-        assert_matches!(ast, Err(FromJsonError::MissingOperator));
+        assert_matches!(serde_json::from_value::<Policy>(bad), Err(e) => {
+            assert_eq!(e.to_string(), "empty map is not a valid expression");
+        });
+
+        let bad = json!(
+            {
+                "effect": "permit",
+                "principal": {
+                    "op": "All"
+                },
+                "action": {
+                    "op": "All"
+                },
+                "resource": {
+                    "op": "All"
+                },
+                "conditions": [
+                    {
+                        "kind": "when",
+                        "body": {
+                            "+": {
+                                "left": {
+                                    "Value": 3
+                                },
+                                "right": {
+                                    "Value": 4
+                                }
+                            },
+                            "-": {
+                                "left": {
+                                    "Value": 8
+                                },
+                                "right": {
+                                    "Value": 2
+                                }
+                            },
+                        }
+                    }
+                ]
+            }
+        );
+        assert_matches!(serde_json::from_value::<Policy>(bad), Err(e) => {
+            assert_eq!(e.to_string(), "JSON object representing an `Expr` should have only one key, but found two keys: `+` and `-`");
+        });
 
         let bad = json!(
             {
@@ -4448,7 +4489,7 @@ mod test {
 
 #[cfg(test)]
 mod issue_891 {
-    use crate::est::{self, FromJsonError};
+    use crate::est;
     use cool_asserts::assert_matches;
     use serde_json::json;
 
@@ -4472,8 +4513,9 @@ mod issue_891 {
     #[test]
     fn invalid_extension_func() {
         let src = est_json_with_body(json!( { "ow4": [ { "Var": "principal" } ] }));
-        let est: est::Policy = serde_json::from_value(src).expect("est JSON should deserialize");
-        assert_matches!(est.try_into_ast_policy(None), Err(FromJsonError::UnknownExtensionFunction(n)) if n == "ow4".parse().unwrap());
+        assert_matches!(serde_json::from_value::<est::Policy>(src), Err(e) => {
+            assert!(e.to_string().starts_with("unknown variant `ow4`, expected one of `Value`, `Var`, "), "e was: {e}");
+        });
 
         let src = est_json_with_body(json!(
             {
@@ -4488,8 +4530,9 @@ mod issue_891 {
                 }
             }
         ));
-        let est: est::Policy = serde_json::from_value(src).expect("est JSON should deserialize");
-        assert_matches!(est.try_into_ast_policy(None), Err(FromJsonError::UnknownExtensionFunction(n)) if n == "ownerOrEqual".parse().unwrap());
+        assert_matches!(serde_json::from_value::<est::Policy>(src), Err(e) => {
+            assert!(e.to_string().starts_with("unknown variant `ownerOrEqual`, expected one of `Value`, `Var`, "), "e was: {e}");
+        });
 
         let src = est_json_with_body(json!(
             {
@@ -4503,8 +4546,9 @@ mod issue_891 {
                 }
             }
         ));
-        let est: est::Policy = serde_json::from_value(src).expect("est JSON should deserialize");
-        assert_matches!(est.try_into_ast_policy(None), Err(FromJsonError::UnknownExtensionFunction(n)) if n == "resorThanOrEqual".parse().unwrap());
+        assert_matches!(serde_json::from_value::<est::Policy>(src), Err(e) => {
+            assert!(e.to_string().starts_with("unknown variant `resorThanOrEqual`, expected one of `Value`, `Var`, "), "e was: {e}");
+        });
     }
 }
 
diff --git a/cedar-policy-core/src/est/expr.rs b/cedar-policy-core/src/est/expr.rs
index 0934ee4d..201c52af 100644
--- a/cedar-policy-core/src/est/expr.rs
+++ b/cedar-policy-core/src/est/expr.rs
@@ -21,6 +21,7 @@ use crate::entities::json::{
     CedarValueJson, FnAndArg, TypeAndId,
 };
 use crate::extensions::Extensions;
+use crate::jsonvalue::JsonValueWithNoDuplicateKeys;
 use crate::parser::cst::{self, Ident};
 use crate::parser::err::{ParseErrors, ToASTError, ToASTErrorKind};
 use crate::parser::unescape::to_unescaped_string;
@@ -28,27 +29,91 @@ use crate::parser::util::flatten_tuple_2;
 use crate::parser::{Loc, Node};
 use either::Either;
 use itertools::Itertools;
-use serde::{Deserialize, Serialize};
+use serde::{de::Visitor, Deserialize, Serialize};
 use serde_with::serde_as;
 use smol_str::{SmolStr, ToSmolStr};
 use std::collections::{BTreeMap, HashMap};
 use std::sync::Arc;
 
 /// Serde JSON structure for a Cedar expression in the EST format
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Serialize)]
 #[serde(untagged)]
 #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
 #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
 pub enum Expr {
     /// Any Cedar expression other than an extension function call.
-    /// We try to match this first, see docs on #[serde(untagged)].
     ExprNoExt(ExprNoExt),
-    /// If that didn't match (because the key is not one of the keys defined in
-    /// `ExprNoExt`), we assume we have an extension function call, where the
-    /// key is the name of an extension function or method.
+    /// Extension function call, where the key is the name of an extension
+    /// function or method.
     ExtFuncCall(ExtFuncCall),
 }
 
+// Manual implementation of `Deserialize` is more efficient than the derived
+// implementation with `serde(untagged)`. In particular, if the key is valid for
+// `ExprNoExt` but there is a deserialization problem within the corresponding
+// value, the derived implementation would backtrack and try to deserialize as
+// `ExtFuncCall` with that key as the extension function name, but this manual
+// implementation instead eagerly errors out, taking advantage of the fact that
+// none of the keys for `ExprNoExt` are valid extension function names.
+//
+// See #1284.
+impl<'de> Deserialize<'de> for Expr {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        struct ExprVisitor;
+        impl<'de> Visitor<'de> for ExprVisitor {
+            type Value = Expr;
+            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+                formatter.write_str("JSON object representing an expression")
+            }
+
+            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
+            where
+                A: serde::de::MapAccess<'de>,
+            {
+                let (k, v): (SmolStr, JsonValueWithNoDuplicateKeys) = match map.next_entry()? {
+                    None => {
+                        return Err(serde::de::Error::custom(
+                            "empty map is not a valid expression",
+                        ))
+                    }
+                    Some((k, v)) => (k, v),
+                };
+                match map.next_key()? {
+                    None => (),
+                    Some(k2) => {
+                        let k2: SmolStr = k2;
+                        return Err(serde::de::Error::custom(format!("JSON object representing an `Expr` should have only one key, but found two keys: `{k}` and `{k2}`")));
+                    }
+                };
+                match ast::Name::parse_unqualified_name(&k)
+                    .map(|name| name.is_known_extension_func_name())
+                {
+                    Ok(true) => {
+                        // `k` is the name of an extension function. We assume that no such keys
+                        // are valid keys for `ExprNoExt`, so we must parse as an `ExtFuncCall`.
+                        let obj = serde_json::json!({ k: v });
+                        let extfunccall =
+                            serde_json::from_value(obj).map_err(serde::de::Error::custom)?;
+                        Ok(Expr::ExtFuncCall(extfunccall))
+                    }
+                    _ => {
+                        // not a valid extension function, so we expect it to work for `ExprNoExt`.
+                        let obj = serde_json::json!({ k: v });
+                        let exprnoext =
+                            serde_json::from_value(obj).map_err(serde::de::Error::custom)?;
+                        Ok(Expr::ExprNoExt(exprnoext))
+                    }
+                }
+            }
+        }
+
+        deserializer.deserialize_map(ExprVisitor)
+    }
+}
+
 /// Represent an element of a pattern literal
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -1371,7 +1436,10 @@ impl TryFrom<&Node<Option<cst::Member>>> for Expr {
                     item = match item {
                         Either::Left(name) => {
                             return Err(node
-                                .to_ast_err(ToASTErrorKind::InvalidAccess(name, id.to_smolstr()))
+                                .to_ast_err(ToASTErrorKind::InvalidAccess {
+                                    lhs: name,
+                                    field: id.to_smolstr(),
+                                })
                                 .into())
                         }
                         Either::Right(expr) => Either::Right(Expr::get_attr(expr, id.to_smolstr())),
@@ -1440,7 +1508,10 @@ impl TryFrom<&Node<Option<cst::Member>>> for Expr {
                     item = match item {
                         Either::Left(name) => {
                             return Err(node
-                                .to_ast_err(ToASTErrorKind::InvalidIndex(name, s))
+                                .to_ast_err(ToASTErrorKind::InvalidIndex {
+                                    lhs: name,
+                                    field: s,
+                                })
                                 .into())
                         }
                         Either::Right(expr) => Either::Right(Expr::get_attr(expr, s)),
diff --git a/cedar-policy-core/src/evaluator.rs b/cedar-policy-core/src/evaluator.rs
index b83d3af9..89527f0b 100644
--- a/cedar-policy-core/src/evaluator.rs
+++ b/cedar-policy-core/src/evaluator.rs
@@ -31,7 +31,7 @@ pub use err::EvaluationError;
 pub(crate) use err::*;
 use evaluation_errors::*;
 use itertools::Either;
-use nonempty::nonempty;
+use nonempty::{nonempty, NonEmpty};
 use smol_str::SmolStr;
 
 #[cfg(not(target_arch = "wasm32"))]
@@ -393,16 +393,44 @@ impl<'e> Evaluator<'e> {
                 match op {
                     BinaryOp::Eq => Ok((arg1 == arg2).into()),
                     // comparison and arithmetic operators, which only work on Longs
-                    BinaryOp::Less
-                    | BinaryOp::LessEq
-                    | BinaryOp::Add
-                    | BinaryOp::Sub
-                    | BinaryOp::Mul => {
+                    BinaryOp::Less | BinaryOp::LessEq => {
+                        let long_op = if matches!(op, BinaryOp::Less) {
+                            |x, y| x < y
+                        } else {
+                            |x, y| x <= y
+                        };
+                        let ext_op = if matches!(op, BinaryOp::Less) {
+                            |x, y| x < y
+                        } else {
+                            |x, y| x <= y
+                        };
+                        match (arg1.value_kind(), arg2.value_kind()) {
+                            (
+                                ValueKind::Lit(Literal::Long(x)),
+                                ValueKind::Lit(Literal::Long(y)),
+                            ) => Ok(long_op(x, y).into()),
+                            (ValueKind::ExtensionValue(x), ValueKind::ExtensionValue(y))
+                                if x.supports_operator_overloading()
+                                    && y.supports_operator_overloading()
+                                    && x.typename() == y.typename() =>
+                            {
+                                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, format!("Only extension types `datetime` and `duration` support operator overloading"))),
+                            // PANIC SAFETY: we're collecting a non-empty vec
+                            #[allow(clippy::unwrap_used)]
+                            _ => Err(EvaluationError::type_error_with_advice(NonEmpty::collect(Extensions::types_with_operator_overloading().map(|name| Type::Extension { name} ).into_iter().chain(std::iter::once(Type::Long))).unwrap(), &arg1, format!("Only `Long` and extension types `datetime`, `duration` support comparison")))
+                        }
+                    }
+                    BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul => {
                         let i1 = arg1.get_as_long()?;
                         let i2 = arg2.get_as_long()?;
                         match op {
-                            BinaryOp::Less => Ok((i1 < i2).into()),
-                            BinaryOp::LessEq => Ok((i1 <= i2).into()),
                             BinaryOp::Add => match i1.checked_add(i2) {
                                 Some(sum) => Ok(sum.into()),
                                 None => {
@@ -2814,7 +2842,7 @@ pub mod test {
     fn interpret_compares() {
         let request = basic_request();
         let entities = basic_entities();
-        let eval = Evaluator::new(request, &entities, Extensions::none());
+        let eval = Evaluator::new(request, &entities, Extensions::all_available());
         // 3 < 303
         assert_eq!(
             eval.interpret_inline_policy(&Expr::less(Expr::val(3), Expr::val(303))),
@@ -2879,171 +2907,171 @@ pub mod test {
         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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // "" < 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // <space>zebras >= zebras
@@ -3053,36 +3081,36 @@ pub mod test {
                 Expr::val("zebras")
             )),
             Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // "" >= ""
         assert_matches!(
             eval.interpret_inline_policy(&Expr::greatereq(Expr::val(""), Expr::val(""))),
             Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // "" >= _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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 🦀 >= _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::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
         // 2 < "4"
@@ -3128,11 +3156,240 @@ pub mod test {
                 Expr::set(vec![Expr::val(47), Expr::val(0)])
             )),
             Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
-                assert_eq!(expected, nonempty![Type::Long]);
+                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, None);
+                assert_eq!(advice, Some("Only `Long` and extension types `datetime`, `duration` support comparison".into()));
             }
         );
+
+        let datetime_constructor: Name = "datetime".parse().unwrap();
+        let duration_constructor: Name = "duration".parse().unwrap();
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::less(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-01-01").into()]),
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-01-23").into()]))),
+            Ok(v) if v == Value::from(true));
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::lesseq(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-01-01").into()]),
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-01-23").into()]))),
+            Ok(v) if v == Value::from(true));
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::less(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-01-01T01:02:03Z").into()]),
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2023-01-23").into()]))),
+            Ok(v) if v == Value::from(false));
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::lesseq(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-01-01T01:02:03Z").into()]),
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2023-01-23").into()]))),
+            Ok(v) if v == Value::from(false));
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::less(
+                Expr::call_extension_fn(
+                    duration_constructor.clone(),
+                    vec![Value::from("5s").into()]),
+                Expr::call_extension_fn(
+                    duration_constructor.clone(),
+                    vec![Value::from("2m").into()]))),
+            Ok(v) if v == Value::from(true));
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::lesseq(
+                Expr::call_extension_fn(
+                    duration_constructor.clone(),
+                    vec![Value::from("1h").into()]),
+                Expr::call_extension_fn(
+                    duration_constructor.clone(),
+                    vec![Value::from("2h").into()]))),
+            Ok(v) if v == Value::from(true));
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::less(
+                Expr::call_extension_fn(
+                    duration_constructor.clone(),
+                    vec![Value::from("3h2m").into()]),
+                Expr::call_extension_fn(
+                    duration_constructor.clone(),
+                    vec![Value::from("2h").into()]))),
+            Ok(v) if v == Value::from(false));
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::lesseq(
+                Expr::call_extension_fn(
+                    duration_constructor.clone(),
+                    vec![Value::from("3h2m").into()]),
+                Expr::call_extension_fn(
+                    duration_constructor.clone(),
+                    vec![Value::from("2h").into()]))),
+            Ok(v) if v == Value::from(false));
+
+        // datetimes that are different times on the same day
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::noteq(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-11-07").into()]),
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-11-07T14:00:00Z").into()]))),
+            Ok(v) if v == Value::from(true));
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::noteq(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-11-07T14:00:00.123Z").into()]),
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-11-07T14:00:00Z").into()]))),
+            Ok(v) if v == Value::from(true));
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::noteq(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-11-07T14:00:00Z").into()]),
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-11-07T17:00:00Z").into()]))),
+            Ok(v) if v == Value::from(true));
+
+        // datetimes that use the UTC offset
+        // both datetimes are UTC 2024-11-07T12:00:00Z
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::noteq(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-11-07T14:00:00+0200").into()]),
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-11-07T11:00:00-0100").into()]))),
+            Ok(v) if v == Value::from(false));
+        // both datetimes are UTC 2024-11-08
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::noteq(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-11-08T02:00:00+0200").into()]),
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-11-07T23:00:00-0100").into()]))),
+            Ok(v) if v == Value::from(false));
+
+        // feb 28 < feb 29 < mar 1 for a leap year
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::less(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-02-28").into()]),
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-02-29").into()]))),
+            Ok(v) if v == Value::from(true));
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::less(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-02-29").into()]),
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2024-03-01").into()]))),
+            Ok(v) if v == Value::from(true));
+
+        // type error favors long and then extension types with operator overloading
+        assert_matches!(eval.interpret_inline_policy(
+        &Expr::lesseq(
+            Value::from(1).into(),
+            Expr::call_extension_fn(
+                duration_constructor.clone(),
+                vec![Value::from("2h").into()]))),
+        Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
+                assert_eq!(expected, nonempty![Type::Long]);
+                assert_eq!(actual, Type::Extension { name: duration_constructor.clone() });
+                assert_eq!(advice, None);
+        });
+
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::lesseq(
+                Expr::call_extension_fn(
+                    duration_constructor.clone(),
+                    vec![Value::from("2h").into()]),
+                Value::from(1).into())),
+            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
+                assert_eq!(expected, nonempty![Type::Long]);
+                assert_eq!(actual, Type::Extension { name: duration_constructor.clone() });
+                assert_eq!(advice, None);
+        });
+
+        assert_matches!(eval.interpret_inline_policy(
+        &Expr::lesseq(
+            Expr::call_extension_fn(
+                duration_constructor.clone(),
+                vec![Value::from("2h").into()]),
+            Expr::call_extension_fn(
+                "decimal".parse().unwrap(),
+                vec![Value::from("2.0").into()]))),
+        Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
+                assert_eq!(expected, nonempty![Type::Extension { name: duration_constructor.clone() }]);
+                assert_eq!(actual, Type::Extension { name: "decimal".parse().unwrap() });
+                assert_eq!(advice, None);
+        });
+
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::lesseq(
+                Expr::call_extension_fn(
+                    "decimal".parse().unwrap(),
+                    vec![Value::from("2.0").into()]),
+                Expr::call_extension_fn(
+                    duration_constructor.clone(),
+                    vec![Value::from("2h").into()]))),
+            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
+                assert_eq!(expected, nonempty![Type::Extension { name: duration_constructor.clone() }]);
+                assert_eq!(actual, Type::Extension { name: "decimal".parse().unwrap() });
+                assert_eq!(advice, None);
+        });
+
+        // if both sides support overloading, favor lhs
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::lesseq(
+                Expr::call_extension_fn(
+                    datetime_constructor.clone(),
+                    vec![Value::from("2023-01-23").into()]),
+                Expr::call_extension_fn(
+                    duration_constructor.clone(),
+                    vec![Value::from("2h").into()]))),
+            Err(EvaluationError::TypeError(TypeError { expected, actual, advice, .. })) => {
+                assert_eq!(expected, nonempty![Type::Extension { name: datetime_constructor.clone() }]);
+                assert_eq!(actual, Type::Extension { name: duration_constructor.clone() });
+                assert_eq!(advice, None);
+        });
+
+        // if both sides are of the same extension type without any operator overloading, remind users those that have
+        assert_matches!(eval.interpret_inline_policy(
+            &Expr::lesseq(
+                Expr::call_extension_fn(
+                    "decimal".parse().unwrap(),
+                    vec![Value::from("2.0").into()]),
+                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.clone() }, Type::Extension { name: duration_constructor.clone() }]);
+                assert_eq!(actual, Type::Extension { name: "decimal".parse().unwrap() });
+                assert_eq!(advice, Some("Only extension types `datetime` and `duration` support operator overloading".into()));
+        });
     }
 
     #[test]
@@ -5662,7 +5919,7 @@ pub mod test {
         let es = Entities::new();
         let eval = Evaluator::new(empty_request(), &es, Extensions::all_available());
 
-        let a = Expr::call_extension_fn("ip".parse().unwrap(), vec![Expr::val("127.0.0.1")]);
+        let a = Expr::call_extension_fn("ip".parse().unwrap(), vec![Expr::val("127.0.0.1/32")]);
         let b = Expr::unknown(Unknown::new_untyped("a"));
         let e = Expr::call_extension_fn("isInRange".parse().unwrap(), vec![a, b]);
 
@@ -5670,7 +5927,7 @@ pub mod test {
 
         assert_eq!(r, PartialValue::Residual(e));
 
-        let b = Expr::call_extension_fn("ip".parse().unwrap(), vec![Expr::val("127.0.0.1")]);
+        let b = Expr::call_extension_fn("ip".parse().unwrap(), vec![Expr::val("127.0.0.1/32")]);
         let a = Expr::unknown(Unknown::new_untyped("a"));
         let e = Expr::call_extension_fn("isInRange".parse().unwrap(), vec![a, b]);
 
diff --git a/cedar-policy-core/src/evaluator/err.rs b/cedar-policy-core/src/evaluator/err.rs
index fcba1b6d..8fd13206 100644
--- a/cedar-policy-core/src/evaluator/err.rs
+++ b/cedar-policy-core/src/evaluator/err.rs
@@ -303,11 +303,13 @@ impl EvaluationError {
         extension_name: Name,
         msg: String,
         source_loc: Option<Loc>,
+        advice: Option<String>,
     ) -> Self {
         evaluation_errors::ExtensionFunctionExecutionError {
             extension_name,
             msg,
             source_loc,
+            advice,
         }
         .into()
     }
@@ -649,12 +651,18 @@ pub mod evaluation_errors {
         pub(crate) extension_name: Name,
         /// Error message from the extension
         pub(crate) msg: String,
+        /// Optional advice for how to fix this error
+        pub(crate) advice: Option<String>,
         /// Source location
         pub(crate) source_loc: Option<Loc>,
     }
 
     impl Diagnostic for ExtensionFunctionExecutionError {
         impl_diagnostic_from_source_loc_opt_field!(source_loc);
+
+        fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
+            self.advice.as_ref().map(|v| Box::new(v) as _)
+        }
     }
 
     impl ExtensionFunctionExecutionError {
diff --git a/cedar-policy-core/src/extensions.rs b/cedar-policy-core/src/extensions.rs
index c60af832..be427918 100644
--- a/cedar-policy-core/src/extensions.rs
+++ b/cedar-policy-core/src/extensions.rs
@@ -21,14 +21,18 @@ pub mod ipaddr;
 
 #[cfg(feature = "decimal")]
 pub mod decimal;
+
+#[cfg(feature = "datetime")]
+pub mod datetime;
 pub mod partial_evaluation;
 
 use std::collections::HashMap;
 
-use crate::ast::{Extension, ExtensionFunction, Name};
+use crate::ast::{Extension, ExtensionFunction, Name, TYPES_WITH_OPERATOR_OVERLOADING};
 use crate::entities::SchemaType;
 use crate::parser::Loc;
 use miette::Diagnostic;
+use nonempty::NonEmpty;
 use thiserror::Error;
 
 use self::extension_function_lookup_errors::FuncDoesNotExistError;
@@ -42,6 +46,8 @@ lazy_static::lazy_static! {
         ipaddr::extension(),
         #[cfg(feature = "decimal")]
         decimal::extension(),
+        #[cfg(feature = "datetime")]
+        datetime::extension(),
         #[cfg(feature = "partial-eval")]
         partial_evaluation::extension(),
     ];
@@ -92,6 +98,13 @@ impl Extensions<'static> {
     pub fn none() -> &'static Extensions<'static> {
         &EXTENSIONS_NONE
     }
+
+    /// Iterate over extension types that support 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()
+    }
 }
 
 impl<'a> Extensions<'a> {
diff --git a/cedar-policy-core/src/extensions/datetime.rs b/cedar-policy-core/src/extensions/datetime.rs
new file mode 100644
index 00000000..0c9072a6
--- /dev/null
+++ b/cedar-policy-core/src/extensions/datetime.rs
@@ -0,0 +1,1578 @@
+/*
+ * 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.
+ */
+
+//! This module contains the Cedar 'datetime' extension.
+use std::{fmt::Display, i64, sync::Arc};
+
+use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeDelta};
+use constants::{
+    DATETIME_CONSTRUCTOR_NAME, DATE_PATTERN, DURATION_CONSTRUCTOR_NAME, DURATION_PATTERN,
+    DURATION_SINCE_NAME, HMS_PATTERN, MS_AND_OFFSET_PATTERN, OFFSET_METHOD_NAME, TO_DATE_NAME,
+};
+use miette::Diagnostic;
+use smol_str::SmolStr;
+use thiserror::Error;
+
+use crate::{
+    ast::{
+        CallStyle, Extension, ExtensionFunction, ExtensionOutputValue, ExtensionValue, Literal,
+        Name, RepresentableExtensionValue, RestrictedExpr, Type, Value, ValueKind,
+    },
+    entities::SchemaType,
+    evaluator::{self, EvaluationError},
+};
+
+const DATETIME_EXTENSION_NAME: &str = "datetime";
+
+// PANIC SAFETY The `Name`s and `Regex` here are valid
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+mod constants {
+    use regex::Regex;
+
+    use crate::{ast::Name, extensions::datetime::DATETIME_EXTENSION_NAME};
+
+    lazy_static::lazy_static! {
+        pub static ref DATETIME_CONSTRUCTOR_NAME : Name = Name::parse_unqualified_name(DATETIME_EXTENSION_NAME).expect("should be a valid identifier");
+        pub static ref DURATION_CONSTRUCTOR_NAME : Name = Name::parse_unqualified_name("duration").expect("should be a valid identifier");
+        pub static ref OFFSET_METHOD_NAME : Name = Name::parse_unqualified_name("offset").expect("should be a valid identifier");
+        pub static ref DURATION_SINCE_NAME : Name = Name::parse_unqualified_name("durationSince").expect("should be a valid identifier");
+        pub static ref TO_DATE_NAME : Name = Name::parse_unqualified_name("toDate").expect("should be a valid identifier");
+        pub static ref TO_TIME_NAME : Name = Name::parse_unqualified_name("toTime").expect("should be a valid identifier");
+        pub static ref TO_MILLISECONDS_NAME : Name = Name::parse_unqualified_name("toMilliseconds").expect("should be a valid identifier");
+        pub static ref TO_SECONDS_NAME : Name = Name::parse_unqualified_name("toSeconds").expect("should be a valid identifier");
+        pub static ref TO_MINUTES_NAME : Name = Name::parse_unqualified_name("toMinutes").expect("should be a valid identifier");
+        pub static ref TO_HOURS_NAME : Name = Name::parse_unqualified_name("toHours").expect("should be a valid identifier");
+        pub static ref TO_DAYS_NAME : Name = Name::parse_unqualified_name("toDays").expect("should be a valid identifier");
+    }
+
+    // Global regex, initialized at first use
+    // PANIC SAFETY: These are valid `Regex`
+    lazy_static::lazy_static! {
+        pub static ref DURATION_PATTERN: Regex =
+        Regex::new(r"^-?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?$").unwrap();
+        pub static ref DATE_PATTERN: Regex = Regex::new(r"^([0-9]{4})-([0-9]{2})-([0-9]{2})").unwrap();
+        pub static ref HMS_PATTERN: Regex = Regex::new(r"^T([0-9]{2}):([0-9]{2}):([0-9]{2})").unwrap();
+        pub static ref MS_AND_OFFSET_PATTERN: Regex =
+        Regex::new(r"^(\.([0-9]{3}))?(Z|((\+|-)([0-9]{2})([0-9]{2})))$").unwrap();
+    }
+}
+
+// The `datetime` type, represented internally as an `i64`.
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
+struct DateTime {
+    // The number of non-leap milliseconds from the Unix epoch
+    epoch: i64,
+}
+
+fn extension_err(
+    msg: String,
+    extension_name: &crate::ast::Name,
+    advice: Option<String>,
+) -> evaluator::EvaluationError {
+    evaluator::EvaluationError::failed_extension_function_application(
+        extension_name.clone(),
+        msg,
+        None, // source loc will be added by the evaluator
+        advice,
+    )
+}
+
+fn construct_from_str<Ext>(
+    arg: Value,
+    constructor_name: Name,
+    constructor: impl Fn(&str) -> Result<Ext, EvaluationError>,
+) -> evaluator::Result<ExtensionOutputValue>
+where
+    Ext: ExtensionValue + std::cmp::Ord + 'static + std::clone::Clone,
+{
+    let s = arg.get_as_string()?;
+    let ext_value: Ext = constructor(s)?;
+    let arg_source_loc = arg.source_loc().cloned();
+    let e =
+        RepresentableExtensionValue::new(Arc::new(ext_value), constructor_name, vec![arg.into()]);
+    Ok(Value {
+        value: ValueKind::ExtensionValue(Arc::new(e)),
+        loc: arg_source_loc, // follow the same convention as the `decimal` extension
+    }
+    .into())
+}
+
+/// Cedar function that constructs a `datetime` Cedar type from a
+/// Cedar string
+fn datetime_from_str(arg: Value) -> evaluator::Result<ExtensionOutputValue> {
+    construct_from_str(arg, DATETIME_CONSTRUCTOR_NAME.clone(), |s| {
+        parse_datetime(s).map(DateTime::from).map_err(|err| {
+            extension_err(
+                err.to_string(),
+                &DATETIME_CONSTRUCTOR_NAME,
+                err.help().map(|v| v.to_string()),
+            )
+        })
+    })
+}
+
+fn as_ext<'a, Ext>(v: &'a Value, type_name: &'a Name) -> Result<&'a Ext, evaluator::EvaluationError>
+where
+    Ext: ExtensionValue + std::cmp::Ord + 'static,
+{
+    match &v.value {
+        ValueKind::ExtensionValue(ev) if ev.typename() == *type_name => {
+            // PANIC SAFETY Conditional above performs a typecheck
+            #[allow(clippy::expect_used)]
+            let ext = ev
+                .value()
+                .as_any()
+                .downcast_ref::<Ext>()
+                .expect("already typechecked, so this downcast should succeed");
+            Ok(ext)
+        }
+        ValueKind::Lit(Literal::String(_)) => {
+            Err(evaluator::EvaluationError::type_error_with_advice_single(
+                Type::Extension {
+                    name: type_name.to_owned(),
+                },
+                v,
+                format!("maybe you forgot to apply the `{type_name}` constructor?"),
+            ))
+        }
+        _ => Err(evaluator::EvaluationError::type_error_single(
+            Type::Extension {
+                name: type_name.to_owned(),
+            },
+            v,
+        )),
+    }
+}
+
+/// Check that `v` is a datetime type and, if it is, return the wrapped value
+fn as_datetime(v: &Value) -> Result<&DateTime, evaluator::EvaluationError> {
+    as_ext(v, &DATETIME_CONSTRUCTOR_NAME)
+}
+
+/// Check that `v` is a duration type and, if it is, return the wrapped value
+fn as_duration(v: &Value) -> Result<&Duration, evaluator::EvaluationError> {
+    as_ext(v, &DURATION_CONSTRUCTOR_NAME)
+}
+
+fn offset(datetime: Value, duration: Value) -> evaluator::Result<ExtensionOutputValue> {
+    let datetime = as_datetime(&datetime)?;
+    let duration = as_duration(&duration)?;
+    let ret = datetime.offset(duration.clone()).ok_or(extension_err(
+        format!(
+            "overflows when adding an offset: {}+({})",
+            RestrictedExpr::from(datetime.clone()),
+            duration
+        ),
+        &OFFSET_METHOD_NAME,
+        None,
+    ))?;
+    Ok(Value {
+        value: ValueKind::ExtensionValue(Arc::new(ret.into())),
+        loc: None,
+    }
+    .into())
+}
+
+fn duration_since(lhs: Value, rhs: Value) -> evaluator::Result<ExtensionOutputValue> {
+    let lhs = as_datetime(&lhs)?;
+    let rhs = as_datetime(&rhs)?;
+    let ret = lhs.duration_since(rhs.clone()).ok_or(extension_err(
+        format!(
+            "overflows when computing the duration between {} and {}",
+            RestrictedExpr::from(lhs.clone()),
+            RestrictedExpr::from(rhs.clone())
+        ),
+        &DURATION_SINCE_NAME,
+        None,
+    ))?;
+    Ok(Value {
+        value: ValueKind::ExtensionValue(Arc::new(ret.into())),
+        loc: None,
+    }
+    .into())
+}
+
+fn to_date(value: Value) -> evaluator::Result<ExtensionOutputValue> {
+    let d = as_datetime(&value)?;
+    let ret = d.to_date().ok_or(extension_err(
+        format!(
+            "overflows when computing the date of {}",
+            RestrictedExpr::from(d.clone()),
+        ),
+        &TO_DATE_NAME,
+        None,
+    ))?;
+    Ok(Value {
+        value: ValueKind::ExtensionValue(Arc::new(ret.into())),
+        loc: None,
+    }
+    .into())
+}
+
+fn to_time(value: Value) -> evaluator::Result<ExtensionOutputValue> {
+    let d = as_datetime(&value)?;
+    let ret = d.to_time();
+    Ok(Value {
+        value: ValueKind::ExtensionValue(Arc::new(ret.into())),
+        loc: None,
+    }
+    .into())
+}
+
+impl ExtensionValue for DateTime {
+    fn typename(&self) -> crate::ast::Name {
+        DATETIME_CONSTRUCTOR_NAME.to_owned()
+    }
+    fn supports_operator_overloading(&self) -> bool {
+        true
+    }
+}
+
+impl DateTime {
+    const DAY_IN_MILLISECONDS: i64 = 1000 * 3600 * 24;
+    const UNIX_EPOCH_STR: &str = "1970-01-01";
+
+    fn offset(&self, duration: Duration) -> Option<Self> {
+        self.epoch
+            .checked_add(duration.ms)
+            .map(|epoch| Self { epoch })
+    }
+
+    fn duration_since(&self, other: DateTime) -> Option<Duration> {
+        self.epoch
+            .checked_sub(other.epoch)
+            .map(|ms| Duration { ms })
+    }
+
+    // essentially `self.epoch.div_floor(Self::DAY_IN_MILLISECONDS) * Self::DAY_IN_MILLISECONDS`
+    // but `div_floor` is only available on nightly
+    fn to_date(&self) -> Option<Self> {
+        if self.epoch.is_negative() {
+            if self.epoch % Self::DAY_IN_MILLISECONDS == 0 {
+                Some(self.epoch)
+            } else {
+                (self.epoch / Self::DAY_IN_MILLISECONDS - 1).checked_mul(Self::DAY_IN_MILLISECONDS)
+            }
+        } else {
+            Some((self.epoch / Self::DAY_IN_MILLISECONDS) * Self::DAY_IN_MILLISECONDS)
+        }
+        .map(|epoch| Self { epoch })
+    }
+
+    fn to_time(&self) -> Duration {
+        Duration {
+            ms: if self.epoch.is_negative() {
+                let rem = self.epoch % Self::DAY_IN_MILLISECONDS;
+                if rem == 0 {
+                    rem
+                } else {
+                    rem + Self::DAY_IN_MILLISECONDS
+                }
+            } else {
+                self.epoch % Self::DAY_IN_MILLISECONDS
+            },
+        }
+    }
+
+    fn as_ext_func_call(&self) -> (Name, Vec<RestrictedExpr>) {
+        (
+            OFFSET_METHOD_NAME.clone(),
+            vec![
+                RestrictedExpr::call_extension_fn(
+                    DATETIME_CONSTRUCTOR_NAME.clone(),
+                    vec![Value::from(DateTime::UNIX_EPOCH_STR).into()],
+                ),
+                Duration { ms: self.epoch }.into(),
+            ],
+        )
+    }
+}
+
+impl From<DateTime> for RestrictedExpr {
+    fn from(value: DateTime) -> Self {
+        let (func, args) = value.as_ext_func_call();
+        Self::call_extension_fn(func, args)
+    }
+}
+
+impl From<DateTime> for RepresentableExtensionValue {
+    fn from(value: DateTime) -> Self {
+        let (func, args) = value.as_ext_func_call();
+        Self {
+            func,
+            args,
+            value: Arc::new(value),
+        }
+    }
+}
+
+impl From<NaiveDateTime> for DateTime {
+    fn from(value: NaiveDateTime) -> Self {
+        let delta = value - NaiveDateTime::UNIX_EPOCH;
+        Self {
+            epoch: delta.num_milliseconds(),
+        }
+    }
+}
+
+// The `duration` type
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+struct Duration {
+    // The number of milliseconds
+    ms: i64,
+}
+
+impl Display for Duration {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}ms", self.ms)
+    }
+}
+
+impl ExtensionValue for Duration {
+    fn typename(&self) -> crate::ast::Name {
+        DURATION_CONSTRUCTOR_NAME.to_owned()
+    }
+    fn supports_operator_overloading(&self) -> bool {
+        true
+    }
+}
+
+impl From<Duration> for RestrictedExpr {
+    fn from(value: Duration) -> Self {
+        let (func, args) = value.as_ext_func_call();
+        RestrictedExpr::call_extension_fn(func, args)
+    }
+}
+
+impl From<Duration> for RepresentableExtensionValue {
+    fn from(value: Duration) -> Self {
+        let (func, args) = value.as_ext_func_call();
+        Self {
+            func,
+            args,
+            value: Arc::new(value),
+        }
+    }
+}
+
+/// Cedar function that constructs a `duration` Cedar type from a
+/// Cedar string
+fn duration_from_str(arg: Value) -> evaluator::Result<ExtensionOutputValue> {
+    construct_from_str(arg, DURATION_CONSTRUCTOR_NAME.clone(), |s| {
+        parse_duration(s).map_err(|err| {
+            extension_err(
+                err.to_string(),
+                &DURATION_CONSTRUCTOR_NAME,
+                err.help().map(|v| v.to_string()),
+            )
+        })
+    })
+}
+
+impl Duration {
+    fn to_milliseconds(&self) -> i64 {
+        self.ms
+    }
+
+    fn to_seconds(&self) -> i64 {
+        self.to_milliseconds() / 1000
+    }
+
+    fn to_minutes(&self) -> i64 {
+        self.to_seconds() / 60
+    }
+
+    fn to_hours(&self) -> i64 {
+        self.to_minutes() / 60
+    }
+
+    fn to_days(&self) -> i64 {
+        self.to_hours() / 24
+    }
+
+    fn as_ext_func_call(&self) -> (Name, Vec<RestrictedExpr>) {
+        (
+            DURATION_CONSTRUCTOR_NAME.clone(),
+            vec![Value::from(self.to_string()).into()],
+        )
+    }
+}
+
+fn duration_method(
+    value: Value,
+    internal_func: impl Fn(&Duration) -> i64,
+) -> evaluator::Result<ExtensionOutputValue> {
+    let d = as_duration(&value)?;
+    Ok(Value::from(internal_func(d)).into())
+}
+
+#[derive(Debug, Clone, Error, Diagnostic)]
+enum DurationParseError {
+    #[error("invalid duration pattern")]
+    #[help("A valid duration string is a concatenated sequence of quantity-unit pairs with an optional `-` at the beginning")]
+    InvalidPattern,
+    #[error("Duration overflows internal representation")]
+    #[help("A duration in milliseconds must be representable by a signed 64 bit integer")]
+    Overflow,
+}
+
+fn parse_duration(s: &str) -> Result<Duration, DurationParseError> {
+    if s.is_empty() || s == "-" {
+        return Err(DurationParseError::InvalidPattern);
+    }
+    let captures = DURATION_PATTERN
+        .captures(s)
+        .ok_or(DurationParseError::InvalidPattern)?;
+    let get_number = |idx| {
+        captures
+            .get(idx)
+            .map_or(Some(0), |m| m.as_str().parse().ok())
+            .ok_or(DurationParseError::Overflow)
+    };
+    let d: u64 = get_number(2)?;
+    let h: u64 = get_number(4)?;
+    let m: u64 = get_number(6)?;
+    let sec: u64 = get_number(8)?;
+    let ms: u64 = get_number(10)?;
+    let checked_op = |x, y: u64, mul| {
+        (if s.starts_with('-') {
+            i64::checked_sub
+        } else {
+            i64::checked_add
+        })(
+            x,
+            i64::checked_mul(y.try_into().map_err(|_| DurationParseError::Overflow)?, mul)
+                .ok_or(DurationParseError::Overflow)?,
+        )
+        .ok_or(DurationParseError::Overflow)
+    };
+    let mut ms = if s.starts_with('-') {
+        i64::try_from(-(ms as i128)).map_err(|_| DurationParseError::Overflow)?
+    } else {
+        i64::try_from(ms).map_err(|_| DurationParseError::Overflow)?
+    };
+    ms = checked_op(ms, sec, 1000)?;
+    ms = checked_op(ms, m, 1000 * 60)?;
+    ms = checked_op(ms, h, 1000 * 60 * 60)?;
+    ms = checked_op(ms, d, 1000 * 60 * 60 * 24)?;
+    Ok(Duration { ms })
+}
+
+#[derive(Debug, Clone, Error, Diagnostic)]
+enum DateTimeParseError {
+    #[error("invalid date pattern")]
+    #[help("A valid datetime string should start with YYYY-MM-DD")]
+    InvalidDatePattern,
+    #[error("invalid date: {0}")]
+    InvalidDate(SmolStr),
+    #[error("invalid hour/minute/second pattern")]
+    #[help("A valid datetime string should have HH:MM:SS after the date")]
+    InvalidHMSPattern,
+    #[error("invalid hour/minute/second: {0}")]
+    InvalidHMS(SmolStr),
+    #[error("invalid millisecond and/or offset pattern")]
+    #[help("A valid datetime should end with Z|.SSSZ|(+|-)hhmm|.SSS(+|-)hhmm")]
+    InvalidMSOffsetPattern,
+    #[error("invalid offset range: {}{}", ._0.0, ._0.1)]
+    #[help("A valid offset hour range should be [0,12) and minute range should be [0, 60)")]
+    InvalidOffset((u32, u32)),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct UTCOffset {
+    positive: bool,
+    hh: u32,
+    mm: u32,
+}
+
+impl UTCOffset {
+    const MIN: Self = Self {
+        positive: false,
+        hh: 12,
+        mm: 0,
+    };
+    const MAX: Self = Self {
+        positive: true,
+        hh: 14,
+        mm: 0,
+    };
+    fn to_seconds(&self) -> i64 {
+        let offset_in_seconds_unsigned = (self.hh * 3600 + self.mm * 60) as i64;
+        if self.positive {
+            offset_in_seconds_unsigned
+        } else {
+            -offset_in_seconds_unsigned
+        }
+    }
+
+    // Reference: https://en.wikipedia.org/wiki/List_of_UTC_offsets
+    fn is_valid(&self) -> bool {
+        self.mm < 60 && self >= &Self::MIN && self <= &Self::MAX
+    }
+}
+
+impl PartialOrd for UTCOffset {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        self.to_seconds().partial_cmp(&other.to_seconds())
+    }
+}
+
+impl Ord for UTCOffset {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        self.to_seconds().cmp(&other.to_seconds())
+    }
+}
+
+fn parse_datetime(s: &str) -> Result<NaiveDateTime, DateTimeParseError> {
+    // Get date first
+    let (date_str, [year, month, day]) = DATE_PATTERN
+        .captures(s)
+        .ok_or(DateTimeParseError::InvalidDatePattern)?
+        .extract();
+
+    // It's a closure because we want to perform syntactical check first and
+    // hence delay semantic check
+    // Both checks are from left to right
+    // PANIC SAFETY: `year`, `month`, and `day` should be all valid given the limit on the number of digits.
+    #[allow(clippy::unwrap_used)]
+    let date = || {
+        NaiveDate::from_ymd_opt(
+            year.parse().unwrap(),
+            month.parse().unwrap(),
+            day.parse().unwrap(),
+        )
+        .ok_or(DateTimeParseError::InvalidDate(date_str.into()))
+    };
+
+    // A complete match; simply return
+    if date_str.len() == s.len() {
+        // PANIC SAFETY: `0`s should be all valid given the limit on the number of digits.
+        #[allow(clippy::unwrap_used)]
+        return Ok(NaiveDateTime::new(
+            date()?,
+            NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
+        ));
+    }
+
+    // Get hour, minute, and second
+    let s = &s[date_str.len()..];
+
+    let (hms_str, [h, m, sec]) = HMS_PATTERN
+        .captures(s)
+        .ok_or(DateTimeParseError::InvalidHMSPattern)?
+        .extract();
+    // PANIC SAFETY: `h`, `m`, and `sec` should be all valid given the limit on the number of digits.
+    #[allow(clippy::unwrap_used)]
+    let (h, m, sec): (u32, u32, u32) =
+        (h.parse().unwrap(), m.parse().unwrap(), sec.parse().unwrap());
+
+    // Get millisecond and offset
+    let s = &s[hms_str.len()..];
+    let captures = MS_AND_OFFSET_PATTERN
+        .captures(s)
+        .ok_or(DateTimeParseError::InvalidMSOffsetPattern)?;
+    let ms: u32 = if captures.get(1).is_some() {
+        // PANIC SAFETY: should be valid given the limit on the number of digits.
+        #[allow(clippy::unwrap_used)]
+        captures[2].parse().unwrap()
+    } else {
+        0
+    };
+
+    let date = date()?;
+    let time = NaiveTime::from_hms_milli_opt(h, m, sec, ms)
+        .ok_or(DateTimeParseError::InvalidHMS(hms_str[1..].into()))?;
+    let offset: Result<TimeDelta, DateTimeParseError> = if captures.get(4).is_some() {
+        let positive = &captures[5] == "+";
+        // PANIC SAFETY: should be valid given the limit on the number of digits.
+        #[allow(clippy::unwrap_used)]
+        let (offset_hour, offset_min): (u32, u32) =
+            (captures[6].parse().unwrap(), captures[7].parse().unwrap());
+        let offset = UTCOffset {
+            positive,
+            hh: offset_hour,
+            mm: offset_min,
+        };
+        if offset.is_valid() {
+            let offset_in_secs = offset.to_seconds();
+            // PANIC SAFETY: should be valid because the limit on the values of offsets.
+            #[allow(clippy::unwrap_used)]
+            Ok(TimeDelta::new(-offset_in_secs, 0).unwrap())
+        } else {
+            Err(DateTimeParseError::InvalidOffset((offset_hour, offset_min)))
+        }
+    } else {
+        Ok(TimeDelta::default())
+    };
+    Ok(NaiveDateTime::new(date, time) + offset?)
+}
+
+/// Construct the extension
+pub fn extension() -> Extension {
+    let datetime_type = SchemaType::Extension {
+        name: DATETIME_CONSTRUCTOR_NAME.to_owned(),
+    };
+    let duration_type = SchemaType::Extension {
+        name: DURATION_CONSTRUCTOR_NAME.to_owned(),
+    };
+    Extension::new(
+        constants::DATETIME_CONSTRUCTOR_NAME.clone(),
+        vec![
+            ExtensionFunction::unary(
+                constants::DATETIME_CONSTRUCTOR_NAME.clone(),
+                CallStyle::FunctionStyle,
+                Box::new(datetime_from_str),
+                datetime_type.clone(),
+                SchemaType::String,
+            ),
+            ExtensionFunction::unary(
+                constants::DURATION_CONSTRUCTOR_NAME.clone(),
+                CallStyle::FunctionStyle,
+                Box::new(duration_from_str),
+                duration_type.clone(),
+                SchemaType::String,
+            ),
+            ExtensionFunction::binary(
+                constants::OFFSET_METHOD_NAME.clone(),
+                CallStyle::MethodStyle,
+                Box::new(offset),
+                datetime_type.clone(),
+                (datetime_type.clone(), duration_type.clone()),
+            ),
+            ExtensionFunction::binary(
+                constants::DURATION_SINCE_NAME.clone(),
+                CallStyle::MethodStyle,
+                Box::new(duration_since),
+                duration_type.clone(),
+                (datetime_type.clone(), duration_type.clone()),
+            ),
+            ExtensionFunction::unary(
+                constants::TO_DATE_NAME.clone(),
+                CallStyle::MethodStyle,
+                Box::new(to_date),
+                datetime_type.clone(),
+                datetime_type.clone(),
+            ),
+            ExtensionFunction::unary(
+                constants::TO_TIME_NAME.clone(),
+                CallStyle::MethodStyle,
+                Box::new(to_time),
+                duration_type.clone(),
+                datetime_type.clone(),
+            ),
+            ExtensionFunction::unary(
+                constants::TO_MILLISECONDS_NAME.clone(),
+                CallStyle::MethodStyle,
+                Box::new(|value| duration_method(value, Duration::to_milliseconds)),
+                SchemaType::Long,
+                duration_type.clone(),
+            ),
+            ExtensionFunction::unary(
+                constants::TO_SECONDS_NAME.clone(),
+                CallStyle::MethodStyle,
+                Box::new(|value| duration_method(value, Duration::to_seconds)),
+                SchemaType::Long,
+                duration_type.clone(),
+            ),
+            ExtensionFunction::unary(
+                constants::TO_MINUTES_NAME.clone(),
+                CallStyle::MethodStyle,
+                Box::new(|value| duration_method(value, Duration::to_minutes)),
+                SchemaType::Long,
+                duration_type.clone(),
+            ),
+            ExtensionFunction::unary(
+                constants::TO_HOURS_NAME.clone(),
+                CallStyle::MethodStyle,
+                Box::new(|value| duration_method(value, Duration::to_hours)),
+                SchemaType::Long,
+                duration_type.clone(),
+            ),
+            ExtensionFunction::unary(
+                constants::TO_DAYS_NAME.clone(),
+                CallStyle::MethodStyle,
+                Box::new(|value| duration_method(value, Duration::to_days)),
+                SchemaType::Long,
+                duration_type.clone(),
+            ),
+        ],
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{str::FromStr, sync::Arc};
+
+    use chrono::NaiveDateTime;
+    use cool_asserts::assert_matches;
+    use nonempty::nonempty;
+
+    use crate::{
+        ast::{Eid, EntityUID, EntityUIDEntry, Expr, Request, Type, Value, ValueKind},
+        entities::Entities,
+        evaluator::{EvaluationError, Evaluator},
+        extensions::{
+            datetime::{
+                constants::{
+                    DURATION_CONSTRUCTOR_NAME, TO_DATE_NAME, TO_DAYS_NAME, TO_HOURS_NAME,
+                    TO_MILLISECONDS_NAME, TO_MINUTES_NAME, TO_SECONDS_NAME, TO_TIME_NAME,
+                },
+                parse_datetime, parse_duration, DateTimeParseError, Duration,
+            },
+            Extensions,
+        },
+        parser::parse_expr,
+    };
+
+    use super::{constants::DATETIME_CONSTRUCTOR_NAME, DateTime};
+
+    #[test]
+    fn test_parse_pos() {
+        let s = "2024-10-15";
+        assert_eq!(
+            parse_datetime(s).unwrap(),
+            NaiveDateTime::from_str("2024-10-15T00:00:00").unwrap()
+        );
+        let s = "2024-10-15T11:38:02Z";
+        assert_eq!(
+            parse_datetime(s).unwrap(),
+            NaiveDateTime::from_str("2024-10-15T11:38:02").unwrap()
+        );
+        let s = "2024-10-15T11:38:02.101Z";
+        assert_eq!(
+            parse_datetime(s).unwrap(),
+            NaiveDateTime::from_str("2024-10-15T11:38:02.101").unwrap()
+        );
+        let s = "2024-10-15T11:38:02.101+1134";
+        assert_eq!(
+            parse_datetime(s).unwrap(),
+            NaiveDateTime::from_str("2024-10-15T00:04:02.101").unwrap()
+        );
+        let s = "2024-10-15T11:38:02.101-1134";
+        assert_eq!(
+            parse_datetime(s).unwrap(),
+            NaiveDateTime::from_str("2024-10-15T23:12:02.101").unwrap()
+        );
+        let s = "2024-10-15T11:38:02+1134";
+        assert_eq!(
+            parse_datetime(s).unwrap(),
+            NaiveDateTime::from_str("2024-10-15T00:04:02").unwrap()
+        );
+        let s = "2024-10-15T11:38:02-1134";
+        assert_eq!(
+            parse_datetime(s).unwrap(),
+            NaiveDateTime::from_str("2024-10-15T23:12:02").unwrap()
+        );
+    }
+
+    #[test]
+    fn test_parse_neg() {
+        for s in [
+            "",
+            "a",
+            "-",
+            "-1",
+            "11-12-13",
+            "1111-1x-20",
+            "2024-10-15Z",
+            "2024-10-15T11:38:02ZZ",
+        ] {
+            assert!(parse_datetime(s).is_err());
+        }
+
+        // invalid dates
+        assert_matches!(
+            parse_datetime("0000-0a-01"),
+            Err(DateTimeParseError::InvalidDatePattern)
+        );
+        assert_matches!(
+            parse_datetime("10000-01-01"),
+            Err(DateTimeParseError::InvalidDatePattern)
+        );
+        assert_matches!(
+            parse_datetime("10000-01-01T00:00:00Z"),
+            Err(DateTimeParseError::InvalidDatePattern)
+        );
+        assert_matches!(parse_datetime("2024-00-01"), Err(DateTimeParseError::InvalidDate(s)) if s == "2024-00-01");
+        assert_matches!(parse_datetime("2024-01-00"), Err(DateTimeParseError::InvalidDate(s)) if s == "2024-01-00");
+        assert_matches!(parse_datetime("2024-02-30"), Err(DateTimeParseError::InvalidDate(s)) if s == "2024-02-30");
+        assert_matches!(parse_datetime("2025-02-29"), Err(DateTimeParseError::InvalidDate(s)) if s == "2025-02-29");
+        assert_matches!(parse_datetime("2024-20-01"), Err(DateTimeParseError::InvalidDate(s)) if s == "2024-20-01");
+        assert_matches!(parse_datetime("2024-01-32"), Err(DateTimeParseError::InvalidDate(s)) if s == "2024-01-32");
+        assert_matches!(parse_datetime("2024-01-99"), Err(DateTimeParseError::InvalidDate(s)) if s == "2024-01-99");
+        assert_matches!(parse_datetime("2024-04-31"), Err(DateTimeParseError::InvalidDate(s)) if s == "2024-04-31");
+
+        // invalid hms
+        assert_matches!(
+            parse_datetime("2024-01-01T"),
+            Err(DateTimeParseError::InvalidHMSPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01Ta"),
+            Err(DateTimeParseError::InvalidHMSPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T01:"),
+            Err(DateTimeParseError::InvalidHMSPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T01:02"),
+            Err(DateTimeParseError::InvalidHMSPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T01:02:0b"),
+            Err(DateTimeParseError::InvalidHMSPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T01::02:03"),
+            Err(DateTimeParseError::InvalidHMSPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T01::02::03"),
+            Err(DateTimeParseError::InvalidHMSPattern)
+        );
+        assert_matches!(parse_datetime("2024-01-01T31:02:03Z"), Err(DateTimeParseError::InvalidHMS(s)) if s == "31:02:03");
+        assert_matches!(parse_datetime("2024-01-01T01:60:03Z"), Err(DateTimeParseError::InvalidHMS(s)) if s == "01:60:03");
+        // we disallow `60`th second (i.e., potentially a leap second) because
+        // we can't check if it's a true leap second or not, though we can
+        // handle any computation if it's deemed to be a valid leap second
+        // Note that `2016-12-31T23:59:60Z` is the latest leap second as of
+        // writing
+        assert_matches!(parse_datetime("2016-12-31T23:59:60Z"), Err(DateTimeParseError::InvalidHMS(s)) if s == "23:59:60");
+        assert_matches!(parse_datetime("2016-12-31T23:59:61Z"), Err(DateTimeParseError::InvalidHMS(s)) if s == "23:59:61");
+
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00T"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00ZZ"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00x001Z"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00.001ZZ"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00➕0000"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00➖0000"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00.0001Z"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00.001➖0000"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00.001➕0000"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00.001+00000"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2024-01-01T00:00:00.001-00000"),
+            Err(DateTimeParseError::InvalidMSOffsetPattern)
+        );
+        assert_matches!(
+            parse_datetime("2016-12-31T00:00:00+1160"),
+            Err(DateTimeParseError::InvalidOffset((11, 60)))
+        );
+        assert_matches!(
+            parse_datetime("2016-12-31T00:00:00+1199"),
+            Err(DateTimeParseError::InvalidOffset((11, 99)))
+        );
+    }
+
+    #[track_caller]
+    fn milliseconds_to_duration(ms: i128) -> String {
+        let sign = if ms < 0 { "-" } else { "" };
+        let mut ms = ms.abs();
+        let milliseconds = ms % 1000;
+        ms /= 1000;
+        let seconds = ms % 60;
+        ms /= 60;
+        let minutes = ms % 60;
+        ms /= 60;
+        let hours = ms % 24;
+        ms /= 24;
+        let days = ms;
+        format!("{sign}{days}d{hours}h{minutes}m{seconds}s{milliseconds}ms")
+    }
+
+    #[test]
+    fn parse_duration_pos() {
+        assert_eq!(parse_duration("1h").unwrap(), Duration { ms: 3600 * 1000 });
+        assert_eq!(
+            parse_duration("-10h").unwrap(),
+            Duration {
+                ms: -3600 * 10 * 1000
+            }
+        );
+        assert_eq!(
+            parse_duration("5d3ms").unwrap(),
+            Duration {
+                ms: 3600 * 24 * 5 * 1000 + 3
+            }
+        );
+        assert_eq!(
+            parse_duration("-3h5m").unwrap(),
+            Duration {
+                ms: -3600 * 3 * 1000 - 300 * 1000
+            }
+        );
+        assert!(parse_duration(&milliseconds_to_duration(i64::MAX.into())).is_ok());
+        assert!(parse_duration(&milliseconds_to_duration(i64::MIN.into())).is_ok());
+    }
+
+    #[test]
+    fn parse_duration_neg() {
+        for s in [
+            "", "a", "-", "-1", "➖1ms", "11dd", "00000mm", "-d", "-h", "-1hh", "-h2d", "-ms",
+            // incorrect ordering
+            "1ms1s", "1ms1m", "1ms1h", "0ms1d", "1s1m", "1s1h", "0s0d", "0m0h", "0m0d", "1h1d",
+            "1ms1m1d",
+        ] {
+            assert!(parse_duration(s).is_err());
+        }
+        assert!(parse_duration(&milliseconds_to_duration(i64::MAX as i128 + 1)).is_err());
+        assert!(parse_duration(&milliseconds_to_duration(i64::MIN as i128 - 1)).is_err());
+    }
+
+    #[test]
+    fn test_offset() {
+        let unix_epoch = DateTime { epoch: 0 };
+        let date_time_max = unix_epoch
+            .offset(parse_duration(&milliseconds_to_duration(i64::MAX.into())).unwrap())
+            .expect("valid datetime");
+        let date_time_min = unix_epoch
+            .offset(parse_duration(&milliseconds_to_duration(i64::MIN.into())).unwrap())
+            .expect("valid datetime");
+        assert!(date_time_max
+            .offset(parse_duration("1ms").unwrap())
+            .is_none());
+        assert_eq!(
+            date_time_max.offset(parse_duration("-1ms").unwrap()),
+            Some(
+                unix_epoch
+                    .offset(
+                        parse_duration(&milliseconds_to_duration(i64::MAX as i128 - 1)).unwrap()
+                    )
+                    .expect("valid datetime")
+            )
+        );
+        assert!(date_time_min
+            .offset(parse_duration("-1ms").unwrap())
+            .is_none());
+        assert_eq!(
+            date_time_min.offset(parse_duration("1ms").unwrap()),
+            Some(
+                unix_epoch
+                    .offset(
+                        parse_duration(&milliseconds_to_duration(i64::MIN as i128 + 1)).unwrap()
+                    )
+                    .expect("valid datetime")
+            )
+        );
+        assert_eq!(
+            unix_epoch.offset(parse_duration("1d").unwrap()),
+            Some(parse_datetime("1970-01-02").unwrap().into())
+        );
+        assert_eq!(
+            unix_epoch.offset(parse_duration("-1d").unwrap()),
+            Some(parse_datetime("1969-12-31").unwrap().into())
+        );
+    }
+
+    #[test]
+    fn test_duration_since() {
+        let unix_epoch = DateTime { epoch: 0 };
+        let today: DateTime = parse_datetime("2024-10-24").unwrap().into();
+        assert_eq!(
+            today.duration_since(unix_epoch.clone()),
+            Some(parse_duration("20020d").unwrap())
+        );
+        let yesterday: DateTime = parse_datetime("2024-10-23").unwrap().into();
+        assert_eq!(
+            yesterday.duration_since(today.clone()),
+            Some(parse_duration("-1d").unwrap())
+        );
+        assert_eq!(
+            today.duration_since(yesterday),
+            Some(parse_duration("1d").unwrap())
+        );
+
+        let date_time_min = unix_epoch
+            .offset(parse_duration(&milliseconds_to_duration(i64::MIN.into())).unwrap())
+            .expect("valid datetime");
+        assert!(today.duration_since(date_time_min).is_none());
+    }
+
+    #[test]
+    fn test_to_date() {
+        let unix_epoch = DateTime { epoch: 0 };
+        let today: DateTime = parse_datetime("2024-10-24").unwrap().into();
+        assert_eq!(
+            today.duration_since(unix_epoch.clone()),
+            Some(parse_duration("20020d").unwrap())
+        );
+        let yesterday: DateTime = parse_datetime("2024-10-23").unwrap().into();
+        assert_eq!(
+            yesterday.duration_since(today.clone()),
+            Some(parse_duration("-1d").unwrap())
+        );
+        let some_day_before_unix_epoch: DateTime = parse_datetime("1900-01-01").unwrap().into();
+
+        let max_day_offset = parse_duration("23h59m59s999ms").unwrap();
+        let min_day_offset = parse_duration("-23h59m59s999ms").unwrap();
+
+        for d in [
+            today,
+            yesterday,
+            unix_epoch.clone(),
+            some_day_before_unix_epoch,
+        ] {
+            assert_eq!(d.to_date().expect("should not overflow"), d);
+            assert_eq!(
+                d.offset(max_day_offset.clone())
+                    .unwrap()
+                    .to_date()
+                    .expect("should not overflow"),
+                d
+            );
+            assert_eq!(
+                d.offset(min_day_offset.clone())
+                    .unwrap()
+                    .to_date()
+                    .expect("should not overflow"),
+                d.offset(parse_duration("-1d").unwrap()).unwrap()
+            );
+        }
+
+        assert!(unix_epoch
+            .offset(Duration { ms: i64::MIN })
+            .expect("should be able to construct")
+            .to_date()
+            .is_none());
+    }
+
+    #[test]
+    fn test_to_time() {
+        let unix_epoch = DateTime { epoch: 0 };
+        let today: DateTime = parse_datetime("2024-10-24").unwrap().into();
+        assert_eq!(
+            today.duration_since(unix_epoch.clone()),
+            Some(parse_duration("20020d").unwrap())
+        );
+        let yesterday: DateTime = parse_datetime("2024-10-23").unwrap().into();
+        assert_eq!(
+            yesterday.duration_since(today.clone()),
+            Some(parse_duration("-1d").unwrap())
+        );
+        let some_day_before_unix_epoch: DateTime = parse_datetime("1900-01-01").unwrap().into();
+
+        let max_day_offset = parse_duration("23h59m59s999ms").unwrap();
+        let min_day_offset = parse_duration("-23h59m59s999ms").unwrap();
+
+        for d in [today, yesterday, unix_epoch, some_day_before_unix_epoch] {
+            assert_eq!(
+                d.offset(max_day_offset.clone()).unwrap().to_time(),
+                max_day_offset
+            );
+            assert_eq!(
+                d.offset(min_day_offset.clone()).unwrap().to_time(),
+                parse_duration("1ms").unwrap(),
+            );
+        }
+    }
+
+    #[test]
+    fn test_predicates() {
+        let unix_epoch = DateTime { epoch: 0 };
+        let today: DateTime = parse_datetime("2024-10-24").unwrap().into();
+        let yesterday: DateTime = parse_datetime("2024-10-23").unwrap().into();
+        let some_day_before_unix_epoch: DateTime = parse_datetime("1900-01-01").unwrap().into();
+        assert!(unix_epoch <= unix_epoch);
+        assert!(today == today);
+        assert!(today != yesterday);
+        assert!(unix_epoch < today);
+        assert!(today > yesterday);
+        assert!(some_day_before_unix_epoch <= unix_epoch);
+        assert!(today >= some_day_before_unix_epoch);
+        assert!(yesterday >= some_day_before_unix_epoch);
+    }
+
+    #[test]
+    fn test_duration_methods() {
+        let day_offset = parse_duration("10d23h59m58s999ms").unwrap();
+        let day_offset_neg = parse_duration("-10d23h59m58s999ms").unwrap();
+        for o in [day_offset, day_offset_neg] {
+            assert_eq!(o.to_days().abs(), 10);
+            assert_eq!(o.to_hours().abs(), 10 * 24 + 23);
+            assert_eq!(o.to_minutes().abs(), (10 * 24 + 23) * 60 + 59);
+            assert_eq!(o.to_seconds().abs(), ((10 * 24 + 23) * 60 + 59) * 60 + 58);
+            assert_eq!(
+                o.to_milliseconds().abs(),
+                (((10 * 24 + 23) * 60 + 59) * 60 + 58) * 1000 + 999
+            );
+        }
+    }
+
+    fn dummy_entity() -> EntityUIDEntry {
+        EntityUIDEntry::Known {
+            euid: Arc::new(EntityUID::from_components(
+                "A".parse().unwrap(),
+                Eid::new(""),
+                None,
+            )),
+            loc: None,
+        }
+    }
+
+    #[test]
+    fn test_interpretation_datetime() {
+        let dummy_entity = dummy_entity();
+        let entities = Entities::default();
+        let eval = Evaluator::new(
+            Request::new_unchecked(
+                dummy_entity.clone(),
+                dummy_entity.clone(),
+                dummy_entity,
+                None,
+            ),
+            &entities,
+            Extensions::all_available(),
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&Expr::call_extension_fn(
+                DATETIME_CONSTRUCTOR_NAME.clone(),
+                vec![Value::from("2024-10-28").into()]
+            )),
+            Ok(Value {
+                value: ValueKind::ExtensionValue(ext),
+                ..
+            }) => {
+                assert!(ext.value().equals_extvalue(&DateTime {epoch: 1730073600000}));
+            }
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&Expr::call_extension_fn(
+                DATETIME_CONSTRUCTOR_NAME.clone(),
+                vec![Value::from("2024-10-28T01:22:33.456Z").into()]
+            )),
+            Ok(Value {
+                value: ValueKind::ExtensionValue(ext),
+                ..
+            }) => {
+                assert!(ext.value().equals_extvalue(&DateTime {epoch: 1730078553456}));
+            }
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&Expr::call_extension_fn(
+                DATETIME_CONSTRUCTOR_NAME.clone(),
+                vec![Value::from("2024-10-28T10:12:13.456-0700").into()]
+            )),
+            Ok(Value {
+                value: ValueKind::ExtensionValue(ext),
+                ..
+            }) => {
+                assert!(ext.value().equals_extvalue(&DateTime {epoch: 1730135533456}));
+            }
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&Expr::call_extension_fn(
+                DATETIME_CONSTRUCTOR_NAME.clone(),
+                vec![Value::from("22024-30-28T10:12:13.456-0700").into()]
+            )),
+            Err(EvaluationError::FailedExtensionFunctionExecution(err)) => {
+                assert_eq!(err.extension_name, *DATETIME_CONSTRUCTOR_NAME);
+                assert_eq!(err.msg, "invalid date pattern".to_owned());
+                // TODO: figure out why it's none given the help annotations
+                assert_eq!(err.advice, None);
+            }
+        );
+
+        // offset the offset component in a datetime specification
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700").offset(duration("-7h"))"#)
+                    .unwrap()
+            )
+            .unwrap(),
+            eval.interpret_inline_policy(
+                &parse_expr(r#"datetime("2024-10-28T10:12:13.456Z")"#).unwrap()
+            )
+            .unwrap()
+        );
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"datetime("2024-10-28T10:12:13.456+0700").offset(duration("7h"))"#)
+                    .unwrap()
+            )
+            .unwrap(),
+            eval.interpret_inline_policy(
+                &parse_expr(r#"datetime("2024-10-28T10:12:13.456Z")"#).unwrap()
+            )
+            .unwrap()
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456+0700").offset("7h")"#).unwrap()),
+            Err(EvaluationError::TypeError(err)) => {
+                assert_eq!(err.expected, nonempty![Type::Extension { name: DURATION_CONSTRUCTOR_NAME.clone() }]);
+                assert_eq!(err.actual, Type::String);
+                assert_eq!(err.advice, Some("maybe you forgot to apply the `duration` constructor?".to_owned()));
+            }
+        );
+
+        // .durationSince
+        assert_eq!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456+0700").durationSince(datetime("2024-10-28T10:12:13.456Z"))"#).unwrap()).unwrap(),
+            eval.interpret_inline_policy(&parse_expr(r#"duration("-7h")"#).unwrap()).unwrap()
+        );
+
+        assert_eq!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700").durationSince(datetime("2024-10-28T10:12:13.456Z"))"#).unwrap()).unwrap(),
+            eval.interpret_inline_policy(&parse_expr(r#"duration("7h")"#).unwrap()).unwrap()
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456+0700").durationSince("7h")"#).unwrap()),
+            Err(EvaluationError::TypeError(err)) => {
+                assert_eq!(err.expected, nonempty![Type::Extension { name: DATETIME_CONSTRUCTOR_NAME.clone() }]);
+                assert_eq!(err.actual, Type::String);
+                assert_eq!(err.advice, Some("maybe you forgot to apply the `datetime` constructor?".to_owned()));
+            }
+        );
+
+        // .toDate
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"datetime("2024-10-28T10:12:13.456+0700").toDate()"#).unwrap()
+            )
+            .unwrap(),
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28")"#).unwrap())
+                .unwrap()
+        );
+
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700").toDate()"#).unwrap()
+            )
+            .unwrap(),
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28")"#).unwrap())
+                .unwrap()
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700").toDate(1)"#).unwrap()),
+            Err(EvaluationError::WrongNumArguments(err)) => {
+                assert_eq!(err.function_name, *TO_DATE_NAME);
+                assert_eq!(err.actual, 2);
+                assert_eq!(err.expected, 1);
+            }
+        );
+
+        // .toTime
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"datetime("2024-10-28T10:12:13.456+0700").toTime()"#).unwrap()
+            )
+            .unwrap(),
+            eval.interpret_inline_policy(&parse_expr(r#"duration("3h12m13s456ms")"#).unwrap())
+                .unwrap()
+        );
+
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700").toTime()"#).unwrap()
+            )
+            .unwrap(),
+            eval.interpret_inline_policy(&parse_expr(r#"duration("17h12m13s456ms")"#).unwrap())
+                .unwrap()
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700").toTime(1)"#).unwrap()),
+            Err(EvaluationError::WrongNumArguments(err)) => {
+                assert_eq!(err.function_name, *TO_TIME_NAME);
+                assert_eq!(err.actual, 2);
+                assert_eq!(err.expected, 1);
+            }
+        );
+
+        // comparisons
+        assert_eq!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700") > datetime("2024-10-28T10:12:13.456Z")"#).unwrap()).unwrap(),
+            Value::from(true),
+        );
+        assert_eq!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700") >= datetime("2024-10-28T10:12:13.456Z")"#).unwrap()).unwrap(),
+            Value::from(true),
+        );
+        assert_eq!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700") != datetime("2024-10-28T10:12:13.456Z")"#).unwrap()).unwrap(),
+            Value::from(true),
+        );
+        assert_eq!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700") == datetime("2024-10-28T17:12:13.456Z")"#).unwrap()).unwrap(),
+            Value::from(true),
+        );
+        assert_eq!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700") < datetime("2024-10-28T17:12:13.456-0800")"#).unwrap()).unwrap(),
+            Value::from(true),
+        );
+        assert_eq!(
+            eval.interpret_inline_policy(&parse_expr(r#"datetime("2024-10-28T10:12:13.456-0700") <= datetime("2024-10-28T17:12:13.456-0800")"#).unwrap()).unwrap(),
+            Value::from(true),
+        );
+    }
+
+    #[test]
+    fn test_interpretation_duration() {
+        let dummy_entity = dummy_entity();
+        let entities = Entities::default();
+        let eval = Evaluator::new(
+            Request::new_unchecked(
+                dummy_entity.clone(),
+                dummy_entity.clone(),
+                dummy_entity,
+                None,
+            ),
+            &entities,
+            Extensions::all_available(),
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&Expr::call_extension_fn(
+                DURATION_CONSTRUCTOR_NAME.clone(),
+                vec![Value::from("1d2h3m4s50ms").into()]
+            )),
+            Ok(Value {
+                value: ValueKind::ExtensionValue(ext),
+                ..
+            }) => {
+                assert!(ext.value().equals_extvalue(&Duration {ms: 93784050}));
+            }
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&Expr::call_extension_fn(
+                DURATION_CONSTRUCTOR_NAME.clone(),
+                vec![Value::from("1dd2h3m4s50ms").into()]
+            )),
+            Err(EvaluationError::FailedExtensionFunctionExecution(err)) => {
+                assert_eq!(err.extension_name, *DURATION_CONSTRUCTOR_NAME);
+                assert_eq!(err.msg, "invalid duration pattern".to_owned());
+                // TODO: figure out why it's none given the help annotations
+                assert_eq!(err.advice, None);
+            }
+        );
+
+        // .toMilliseconds
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("1d2h3m4s50ms").toMilliseconds()"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(93784050)
+        );
+
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("-1d2h3m4s50ms").toMilliseconds()"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(-93784050)
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&parse_expr(r#"duration("-1d2h3m4s50ms").toMilliseconds(1)"#).unwrap()),
+            Err(EvaluationError::WrongNumArguments(err)) => {
+                assert_eq!(err.function_name, *TO_MILLISECONDS_NAME);
+                assert_eq!(err.actual, 2);
+                assert_eq!(err.expected, 1);
+            }
+        );
+
+        // .toSeconds
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("1d2h3m4s50ms").toSeconds()"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(93784)
+        );
+
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("-1d2h3m4s50ms").toSeconds()"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(-93784)
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&parse_expr(r#"duration("-1d2h3m4s50ms").toSeconds(1)"#).unwrap()),
+            Err(EvaluationError::WrongNumArguments(err)) => {
+                assert_eq!(err.function_name, *TO_SECONDS_NAME);
+                assert_eq!(err.actual, 2);
+                assert_eq!(err.expected, 1);
+            }
+        );
+
+        // .toMinutes
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("1d2h3m4s50ms").toMinutes()"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(1563)
+        );
+
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("-1d2h3m4s50ms").toMinutes()"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(-1563)
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&parse_expr(r#"duration("-1d2h3m4s50ms").toMinutes(1)"#).unwrap()),
+            Err(EvaluationError::WrongNumArguments(err)) => {
+                assert_eq!(err.function_name, *TO_MINUTES_NAME);
+                assert_eq!(err.actual, 2);
+                assert_eq!(err.expected, 1);
+            }
+        );
+
+        // .toHours
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("1d2h3m4s50ms").toHours()"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(26)
+        );
+
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("-1d2h3m4s50ms").toHours()"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(-26)
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&parse_expr(r#"duration("-1d2h3m4s50ms").toHours(1)"#).unwrap()),
+            Err(EvaluationError::WrongNumArguments(err)) => {
+                assert_eq!(err.function_name, *TO_HOURS_NAME);
+                assert_eq!(err.actual, 2);
+                assert_eq!(err.expected, 1);
+            }
+        );
+
+        // .toDays
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("1d2h3m4s50ms").toDays()"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(1)
+        );
+
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("-1d2h3m4s50ms").toDays()"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(-1)
+        );
+
+        assert_matches!(
+            eval.interpret_inline_policy(&parse_expr(r#"duration("-1d2h3m4s50ms").toDays(1)"#).unwrap()),
+            Err(EvaluationError::WrongNumArguments(err)) => {
+                assert_eq!(err.function_name, *TO_DAYS_NAME);
+                assert_eq!(err.actual, 2);
+                assert_eq!(err.expected, 1);
+            }
+        );
+
+        // Python's datetime does this but is -2h shorter than 1h?
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("-2h") < duration("1h")"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(true),
+        );
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("-2h") <= duration("1h")"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(true),
+        );
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("-2h") != duration("1h")"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(true),
+        );
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("-3d") == duration("-72h")"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(true),
+        );
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("2h") > duration("1h")"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(true),
+        );
+        assert_eq!(
+            eval.interpret_inline_policy(
+                &parse_expr(r#"duration("2h") >= duration("1h")"#).unwrap()
+            )
+            .unwrap(),
+            Value::from(true),
+        );
+    }
+}
diff --git a/cedar-policy-core/src/extensions/decimal.rs b/cedar-policy-core/src/extensions/decimal.rs
index 4eae7463..7e3081de 100644
--- a/cedar-policy-core/src/extensions/decimal.rs
+++ b/cedar-policy-core/src/extensions/decimal.rs
@@ -17,8 +17,8 @@
 //! This module contains the Cedar 'decimal' extension.
 
 use crate::ast::{
-    CallStyle, Extension, ExtensionFunction, ExtensionOutputValue, ExtensionValue,
-    ExtensionValueWithArgs, Literal, Name, Type, Value, ValueKind,
+    CallStyle, Extension, ExtensionFunction, ExtensionOutputValue, ExtensionValue, Literal, Name,
+    RepresentableExtensionValue, Type, Value, ValueKind,
 };
 use crate::entities::SchemaType;
 use crate::evaluator;
@@ -166,15 +166,19 @@ impl ExtensionValue for Decimal {
     fn typename(&self) -> Name {
         Self::typename()
     }
+    fn supports_operator_overloading(&self) -> bool {
+        false
+    }
 }
 
 const EXTENSION_NAME: &str = "decimal";
 
-fn extension_err(msg: impl Into<String>) -> evaluator::EvaluationError {
+fn extension_err(msg: impl Into<String>, advice: Option<String>) -> evaluator::EvaluationError {
     evaluator::EvaluationError::failed_extension_function_application(
         constants::DECIMAL_FROM_STR_NAME.clone(),
         msg.into(),
-        None, // source loc will be added by the evaluator
+        None,
+        advice.map(Into::into), // source loc will be added by the evaluator
     )
 }
 
@@ -182,10 +186,14 @@ fn extension_err(msg: impl Into<String>) -> evaluator::EvaluationError {
 /// Cedar string
 fn decimal_from_str(arg: Value) -> evaluator::Result<ExtensionOutputValue> {
     let str = arg.get_as_string()?;
-    let decimal = Decimal::from_str(str.as_str()).map_err(|e| extension_err(e.to_string()))?;
-    let function_name = constants::DECIMAL_FROM_STR_NAME.clone();
+    let decimal =
+        Decimal::from_str(str.as_str()).map_err(|e| extension_err(e.to_string(), None))?;
     let arg_source_loc = arg.source_loc().cloned();
-    let e = ExtensionValueWithArgs::new(Arc::new(decimal), function_name, vec![arg.into()]);
+    let e = RepresentableExtensionValue::new(
+        Arc::new(decimal),
+        constants::DECIMAL_FROM_STR_NAME.clone(),
+        vec![arg.into()],
+    );
     Ok(Value {
         value: ValueKind::ExtensionValue(Arc::new(e)),
         loc: arg_source_loc, // this gives the loc of the arg. We could perhaps give instead the loc of the entire `decimal("x.yz")` call, but that is hard to do at this program point
@@ -619,12 +627,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::Long]);
+                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}]);
                 assert_eq!(actual, Type::Extension {
                     name: Name::parse_unqualified_name("decimal")
                         .expect("should be a valid identifier")
                 });
-                assert_eq!(advice, None);
+                assert_eq!(advice, Some("Only extension types `datetime` and `duration` support operator overloading".into()));
             }
         );
         assert_matches!(
diff --git a/cedar-policy-core/src/extensions/ipaddr.rs b/cedar-policy-core/src/extensions/ipaddr.rs
index 545d96c4..2eeb6963 100644
--- a/cedar-policy-core/src/extensions/ipaddr.rs
+++ b/cedar-policy-core/src/extensions/ipaddr.rs
@@ -17,8 +17,8 @@
 //! This module contains the Cedar 'ipaddr' extension.
 
 use crate::ast::{
-    CallStyle, Extension, ExtensionFunction, ExtensionOutputValue, ExtensionValue,
-    ExtensionValueWithArgs, Literal, Name, Type, Value, ValueKind,
+    CallStyle, Extension, ExtensionFunction, ExtensionOutputValue, ExtensionValue, Literal, Name,
+    RepresentableExtensionValue, Type, Value, ValueKind,
 };
 use crate::entities::SchemaType;
 use crate::evaluator;
@@ -227,6 +227,9 @@ impl ExtensionValue for IPAddr {
     fn typename(&self) -> Name {
         Self::typename()
     }
+    fn supports_operator_overloading(&self) -> bool {
+        false
+    }
 }
 
 fn extension_err(msg: impl Into<String>) -> evaluator::EvaluationError {
@@ -234,6 +237,7 @@ fn extension_err(msg: impl Into<String>) -> evaluator::EvaluationError {
         names::EXTENSION_NAME.clone(),
         msg.into(),
         None, // source loc will be added by the evaluator
+        None,
     )
 }
 
@@ -303,11 +307,10 @@ fn str_contains_colons_and_dots(s: &str) -> Result<(), String> {
 /// Cedar string
 fn ip_from_str(arg: Value) -> evaluator::Result<ExtensionOutputValue> {
     let str = arg.get_as_string()?;
-    let function_name = names::IP_FROM_STR_NAME.clone();
     let arg_source_loc = arg.source_loc().cloned();
-    let ipaddr = ExtensionValueWithArgs::new(
+    let ipaddr = RepresentableExtensionValue::new(
         Arc::new(IPAddr::from_str(str.as_str()).map_err(extension_err)?),
-        function_name,
+        names::IP_FROM_STR_NAME.clone(),
         vec![arg.into()],
     );
     Ok(Value {
@@ -605,12 +608,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::Long]);
+                assert_eq!(expected, nonempty![Type::Extension { name: "datetime".parse().unwrap()}, Type::Extension { name: "duration".parse().unwrap()}]);
                 assert_eq!(actual, Type::Extension {
                     name: Name::parse_unqualified_name("ipaddr")
                         .expect("should be a valid identifier")
                 });
-                assert_eq!(advice, None);
+                assert_eq!(advice, Some("Only extension types `datetime` and `duration` support operator overloading".into()));
             }
         );
         // test that isIpv4 on a String is an error
@@ -634,13 +637,13 @@ mod tests {
             eval.interpret_inline_policy(&ip("127.0.0.1"))
                 .unwrap()
                 .to_string(),
-            "127.0.0.1/32"
+            r#"ip("127.0.0.1")"#
         );
         assert_eq!(
             eval.interpret_inline_policy(&ip("ffee::11"))
                 .unwrap()
                 .to_string(),
-            "ffee::11/128"
+            r#"ip("ffee::11")"#
         );
     }
 
@@ -704,25 +707,25 @@ mod tests {
             eval.interpret_inline_policy(&ip("127.0.0.1/0"))
                 .unwrap()
                 .to_string(),
-            "127.0.0.1/0"
+            r#"ip("127.0.0.1/0")"#
         );
         assert_eq!(
             eval.interpret_inline_policy(&ip("127.0.0.1/8"))
                 .unwrap()
                 .to_string(),
-            "127.0.0.1/8"
+            r#"ip("127.0.0.1/8")"#
         );
         assert_eq!(
             eval.interpret_inline_policy(&ip("127.0.0.1/32"))
                 .unwrap()
                 .to_string(),
-            "127.0.0.1/32"
+            r#"ip("127.0.0.1/32")"#
         );
         assert_eq!(
             eval.interpret_inline_policy(&ip("ffee::/64"))
                 .unwrap()
                 .to_string(),
-            "ffee::/64"
+            r#"ip("ffee::/64")"#
         );
     }
 
diff --git a/cedar-policy-core/src/parser/cst_to_ast.rs b/cedar-policy-core/src/parser/cst_to_ast.rs
index c5e636ab..c59745f4 100644
--- a/cedar-policy-core/src/parser/cst_to_ast.rs
+++ b/cedar-policy-core/src/parser/cst_to_ast.rs
@@ -1274,16 +1274,19 @@ impl Node<Option<cst::Member>> {
                 // attribute access on an arbitrary name - error
                 (Name { name, .. }, [Field(f), ..]) => {
                     return Err(self
-                        .to_ast_err(ToASTErrorKind::InvalidAccess(
-                            name.clone(),
-                            f.to_string().into(),
-                        ))
+                        .to_ast_err(ToASTErrorKind::InvalidAccess {
+                            lhs: name.clone(),
+                            field: f.to_smolstr(),
+                        })
                         .into());
                 }
                 // index style attribute access on an arbitrary name - error
                 (Name { name, .. }, [Index(i), ..]) => {
                     return Err(self
-                        .to_ast_err(ToASTErrorKind::InvalidIndex(name.clone(), i.clone()))
+                        .to_ast_err(ToASTErrorKind::InvalidIndex {
+                            lhs: name.clone(),
+                            field: i.clone(),
+                        })
                         .into());
                 }
 
diff --git a/cedar-policy-core/src/parser/err.rs b/cedar-policy-core/src/parser/err.rs
index c2a31102..0a017a01 100644
--- a/cedar-policy-core/src/parser/err.rs
+++ b/cedar-policy-core/src/parser/err.rs
@@ -323,11 +323,21 @@ pub enum ToASTErrorKind {
     #[error("function calls must be of the form `<name>(arg1, arg2, ...)`")]
     ExpressionCall,
     /// Returned when a policy attempts to access the fields of a value with no fields
-    #[error("invalid member access `{0}.{1}`, `{0}` has no fields or methods")]
-    InvalidAccess(ast::Name, SmolStr),
+    #[error("invalid member access `{lhs}.{field}`, `{lhs}` has no fields or methods")]
+    InvalidAccess {
+        /// what we attempted to access a field of
+        lhs: ast::Name,
+        /// field we attempted to access
+        field: SmolStr,
+    },
     /// Returned when a policy attempts to index on a fields of a value with no fields
-    #[error("invalid indexing expression `{0}[\"{}\"]`, `{0}` has no fields", .1.escape_debug())]
-    InvalidIndex(ast::Name, SmolStr),
+    #[error("invalid indexing expression `{lhs}[\"{}\"]`, `{lhs}` has no fields", .field.escape_debug())]
+    InvalidIndex {
+        /// what we attempted to access a field of
+        lhs: ast::Name,
+        /// field we attempted to access
+        field: SmolStr,
+    },
     /// Returned when the contents of an indexing expression is not a string literal
     #[error("the contents of an index expression must be a string literal")]
     NonStringIndex,
diff --git a/cedar-policy-validator/Cargo.toml b/cedar-policy-validator/Cargo.toml
index 27bc87ef..5c799b8d 100644
--- a/cedar-policy-validator/Cargo.toml
+++ b/cedar-policy-validator/Cargo.toml
@@ -16,7 +16,7 @@ serde = { version = "1.0", features = ["derive"] }
 serde_json = { version = "1.0", features = ["preserve_order"] }
 serde_with = "3.0"
 miette = "7.1.0"
-thiserror = "1.0"
+thiserror = "2.0"
 itertools = "0.13"
 ref-cast = "1.0"
 unicode-security = "0.1.0"
@@ -37,10 +37,11 @@ prost = { version = "0.13.3", optional = true }
 
 [features]
 # by default, enable all Cedar extensions
-default = ["ipaddr", "decimal"]
+default = ["ipaddr", "decimal", "datetime"]
 # when enabling a feature, make sure that the Core feature is also enabled
 ipaddr = ["cedar-policy-core/ipaddr"]
 decimal = ["cedar-policy-core/decimal"]
+datetime = ["cedar-policy-core/datetime"]
 partial-eval = ["cedar-policy-core/partial-eval"]
 
 # Enables `Arbitrary` implementations for several types in this crate
diff --git a/cedar-policy-validator/src/extensions.rs b/cedar-policy-validator/src/extensions.rs
index 18b78114..da393d13 100644
--- a/cedar-policy-validator/src/extensions.rs
+++ b/cedar-policy-validator/src/extensions.rs
@@ -37,6 +37,9 @@ pub mod ipaddr;
 #[cfg(feature = "decimal")]
 pub mod decimal;
 
+#[cfg(feature = "datetime")]
+pub mod datetime;
+
 pub mod partial_evaluation;
 
 lazy_static::lazy_static! {
@@ -45,6 +48,8 @@ lazy_static::lazy_static! {
         ipaddr::extension_schema(),
         #[cfg(feature = "decimal")]
         decimal::extension_schema(),
+        #[cfg(feature = "datetime")]
+        datetime::extension_schema(),
         #[cfg(feature = "partial-eval")]
         partial_evaluation::extension_schema(),
     ];
diff --git a/cedar-policy-validator/src/extensions/datetime.rs b/cedar-policy-validator/src/extensions/datetime.rs
new file mode 100644
index 00000000..164063e0
--- /dev/null
+++ b/cedar-policy-validator/src/extensions/datetime.rs
@@ -0,0 +1,155 @@
+/*
+ * 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.
+ */
+//! Note on panic safety
+//! If any of the panics in this file are triggered, that means that this file has become
+//! out-of-date with the decimal extension definition in Core.
+//! This is tested by the `extension_schema_correctness()` test
+
+use crate::extension_schema::{ArgumentCheckFn, ExtensionFunctionType, ExtensionSchema};
+use crate::types::{self, Type};
+use cedar_policy_core::ast::{Expr, ExprKind, Literal, Name};
+use cedar_policy_core::extensions::datetime;
+use itertools::Itertools;
+
+use super::eval_extension_constructor;
+
+/// Note on safety:
+/// This module depends on the Cedar parser only constructing AST with valid extension calls
+/// If any of the panics in this file are triggered, that means that this file has become
+/// out-of-date with the datetime extension definition in Core.
+
+// PANIC SAFETY see `Note on safety` above
+#[allow(clippy::panic)]
+fn get_argument_types(fname: &Name, datetime_ty: &Type, duration_ty: &Type) -> Vec<types::Type> {
+    if !fname.as_ref().is_unqualified() {
+        panic!("unexpected datetime extension function name: {fname}")
+    }
+    match fname.basename().as_ref() {
+        "datetime" | "duration" => vec![Type::primitive_string()],
+        "offset" => vec![datetime_ty.clone(), duration_ty.clone()],
+        "durationSince" => vec![datetime_ty.clone(), datetime_ty.clone()],
+        "toDate" => vec![datetime_ty.clone()],
+        "toTime" => vec![datetime_ty.clone()],
+        "toMilliseconds" | "toSeconds" | "toMinutes" | "toHours" | "toDays" => {
+            vec![duration_ty.clone()]
+        }
+        _ => panic!("unexpected datetime extension function name: {fname}"),
+    }
+}
+
+// PANIC SAFETY see `Note on safety` above
+#[allow(clippy::panic)]
+fn get_return_type(fname: &Name, datetime_ty: &Type, duration_ty: &Type) -> Type {
+    if !fname.as_ref().is_unqualified() {
+        panic!("unexpected datetime extension function name: {fname}")
+    }
+    match fname.basename().as_ref() {
+        "datetime" | "offset" | "toDate" => datetime_ty.clone(),
+        "duration" | "durationSince" | "toTime" => duration_ty.clone(),
+        "toMilliseconds" | "toSeconds" | "toMinutes" | "toHours" | "toDays" => {
+            Type::primitive_long()
+        }
+        _ => panic!("unexpected datetime extension function name: {fname}"),
+    }
+}
+
+// PANIC SAFETY see `Note on safety` above
+#[allow(clippy::panic)]
+fn get_argument_check(fname: &Name) -> Option<ArgumentCheckFn> {
+    if !fname.as_ref().is_unqualified() {
+        panic!("unexpected datetime extension function name: {fname}")
+    }
+    match fname.basename().as_ref() {
+        "datetime" => {
+            let fname = fname.clone();
+            Some(Box::new(move |args| {
+                validate_datetime_string(fname.clone(), args)
+            }))
+        }
+        "duration" => {
+            let fname = fname.clone();
+            Some(Box::new(move |args| {
+                validate_duration_string(fname.clone(), args)
+            }))
+        }
+        "offset" | "durationSince" | "toDate" | "toTime" | "toMilliseconds" | "toSeconds"
+        | "toMinutes" | "toHours" | "toDays" => None,
+        _ => panic!("unexpected datetime extension function name: {fname}"),
+    }
+}
+
+/// Construct the extension schema
+pub fn extension_schema() -> ExtensionSchema {
+    let datetime_ext = datetime::extension();
+    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 fun_tys = datetime_ext.funcs().map(|f| {
+        let return_type = get_return_type(f.name(), &datetime_ty, &duration_ty);
+        debug_assert!(f
+            .return_type()
+            .map(|ty| return_type.is_consistent_with(ty))
+            .unwrap_or_else(|| return_type == Type::Never));
+        ExtensionFunctionType::new(
+            f.name().clone(),
+            get_argument_types(f.name(), &datetime_ty, &duration_ty),
+            return_type,
+            get_argument_check(f.name()),
+        )
+    });
+    ExtensionSchema::new(datetime_ext.name().clone(), fun_tys)
+}
+
+/// Extra validation step for the `datetime` function.
+/// Note we already checked that `exprs` contains correct number of arguments,
+/// these arguments have the correct types, and that they are all literals.
+fn validate_datetime_string(datetime_constructor_name: Name, exprs: &[Expr]) -> Result<(), String> {
+    match exprs.iter().exactly_one().map(|a| a.expr_kind()) {
+        Ok(ExprKind::Lit(lit_arg @ Literal::String(s))) => {
+            eval_extension_constructor(datetime_constructor_name, s.clone())
+                .map(|_| ())
+                .map_err(|_| format!("Failed to parse as a datetime value: `{lit_arg}`"))
+        }
+        _ => Ok(()),
+    }
+}
+
+/// Extra validation step for the `duration` function.
+/// Note we already checked that `exprs` contains correct number of arguments,
+/// these arguments have the correct types, and that they are all literals.
+fn validate_duration_string(duration_constructor_name: Name, exprs: &[Expr]) -> Result<(), String> {
+    match exprs.iter().exactly_one().map(|a| a.expr_kind()) {
+        Ok(ExprKind::Lit(lit_arg @ Literal::String(s))) => {
+            eval_extension_constructor(duration_constructor_name, s.clone())
+                .map(|_| ())
+                .map_err(|_| format!("Failed to parse as a duration value: `{lit_arg}`"))
+        }
+        _ => Ok(()),
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    // Ensures that `extension_schema()` does not panic
+    #[test]
+    fn extension_schema_correctness() {
+        let _ = extension_schema();
+    }
+}
diff --git a/cedar-policy-validator/src/schema.rs b/cedar-policy-validator/src/schema.rs
index 40f8304c..bebbd095 100644
--- a/cedar-policy-validator/src/schema.rs
+++ b/cedar-policy-validator/src/schema.rs
@@ -3043,7 +3043,7 @@ pub(crate) mod test {
                 &src,
                 &miette::Report::new(e),
                 &ExpectedErrorMessageBuilder::error("unknown extension type `partial_evaluation`")
-                    .help("did you mean `decimal`?")
+                    .help("did you mean `duration`?")
                     .build());
         });
     }
diff --git a/cedar-policy-validator/src/typecheck.rs b/cedar-policy-validator/src/typecheck.rs
index 6b69c1a0..f9b1e6f9 100644
--- a/cedar-policy-validator/src/typecheck.rs
+++ b/cedar-policy-validator/src/typecheck.rs
@@ -21,6 +21,7 @@
 pub(crate) mod test;
 
 mod typecheck_answer;
+use itertools::Itertools;
 pub(crate) use typecheck_answer::TypecheckAnswer;
 
 use std::{borrow::Cow, collections::HashSet, iter::zip};
@@ -37,11 +38,14 @@ use crate::{
     ValidationError, ValidationMode, ValidationWarning,
 };
 
-use cedar_policy_core::ast::{
-    BinaryOp, EntityType, EntityUID, Expr, ExprBuilder, ExprKind, Literal, Name, PolicyID,
-    PrincipalOrResourceConstraint, SlotId, Template, UnaryOp, Var,
-};
 use cedar_policy_core::fuzzy_match::fuzzy_search;
+use cedar_policy_core::{
+    ast::{
+        BinaryOp, EntityType, EntityUID, Expr, ExprBuilder, ExprKind, Literal, Name, PolicyID,
+        PrincipalOrResourceConstraint, SlotId, Template, UnaryOp, Var,
+    },
+    extensions::Extensions,
+};
 
 #[cfg(not(target_arch = "wasm32"))]
 const REQUIRED_STACK_SPACE: usize = 1024 * 100;
@@ -1244,29 +1248,123 @@ impl<'a> Typechecker<'a> {
             }
 
             BinaryOp::Less | BinaryOp::LessEq => {
-                let ans_arg1 = self.expect_type(
-                    request_env,
-                    prior_capability,
-                    arg1,
-                    Type::primitive_long(),
-                    type_errors,
-                    |_| None,
-                );
+                let expected_types = Extensions::types_with_operator_overloading()
+                    .into_iter()
+                    .map(Type::extension)
+                    .chain(std::iter::once(Type::primitive_long()))
+                    .collect_vec();
+                let ans_arg1 = self.typecheck(request_env, prior_capability, &arg1, type_errors);
                 ans_arg1.then_typecheck(|expr_ty_arg1, _| {
-                    let ans_arg2 = self.expect_type(
-                        request_env,
-                        prior_capability,
-                        arg2,
-                        Type::primitive_long(),
-                        type_errors,
-                        |_| None,
-                    );
+                    let ans_arg2 =
+                        self.typecheck(request_env, prior_capability, &arg2, type_errors);
                     ans_arg2.then_typecheck(|expr_ty_arg2, _| {
-                        TypecheckAnswer::success(
-                            ExprBuilder::with_data(Some(Type::primitive_boolean()))
-                                .with_same_source_loc(bin_expr)
-                                .binary_app(*op, expr_ty_arg1, expr_ty_arg2),
-                        )
+                        let expr = ExprBuilder::with_data(Some(Type::primitive_boolean()))
+                            .with_same_source_loc(bin_expr)
+                            .binary_app(*op, expr_ty_arg1.clone(), expr_ty_arg2.clone());
+                        let t1 = expr_ty_arg1.data().as_ref();
+                        let t2 = expr_ty_arg2.data().as_ref();
+                        match (t1, t2) {
+                            (Some(Type::Never), Some(Type::Never)) => TypecheckAnswer::fail(expr),
+                            (Some(Type::Never), Some(other)) => {
+                                if expected_types.contains(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,
+                                        other.clone(),
+                                        None,
+                                    ));
+                                    TypecheckAnswer::fail(expr)
+                                }
+                            }
+                            (Some(other), Some(Type::Never)) => {
+                                if expected_types.contains(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,
+                                        other.clone(),
+                                        None,
+                                    ));
+                                    TypecheckAnswer::fail(expr)
+                                }
+                            }
+                            (Some(t1), Some(t2)) if t1 == t2 && expected_types.contains(t1) => {
+                                TypecheckAnswer::success(expr)
+                            }
+                            (
+                                Some(Type::Primitive {
+                                    primitive_type: Primitive::Long,
+                                }),
+                                Some(other),
+                            ) => {
+                                type_errors.push(ValidationError::expected_one_of_types(
+                                    expr_ty_arg2.source_loc().cloned(),
+                                    self.policy_id.clone(),
+                                    vec![Type::primitive_long()],
+                                    other.clone(),
+                                    None,
+                                ));
+                                TypecheckAnswer::fail(expr)
+                            }
+                            (
+                                Some(other),
+                                Some(Type::Primitive {
+                                    primitive_type: Primitive::Long,
+                                }),
+                            ) => {
+                                type_errors.push(ValidationError::expected_one_of_types(
+                                    expr_ty_arg1.source_loc().cloned(),
+                                    self.policy_id.clone(),
+                                    vec![Type::primitive_long()],
+                                    other.clone(),
+                                    None,
+                                ));
+                                TypecheckAnswer::fail(expr)
+                            }
+                            (Some(lhs), Some(rhs)) if lhs.support_operator_overloading() => {
+                                type_errors.push(ValidationError::expected_one_of_types(
+                                    expr_ty_arg2.source_loc().cloned(),
+                                    self.policy_id.clone(),
+                                    vec![lhs.clone()],
+                                    rhs.clone(),
+                                    None,
+                                ));
+                                TypecheckAnswer::fail(expr)
+                            }
+                            (Some(lhs), Some(rhs)) if rhs.support_operator_overloading() => {
+                                type_errors.push(ValidationError::expected_one_of_types(
+                                    expr_ty_arg1.source_loc().cloned(),
+                                    self.policy_id.clone(),
+                                    vec![rhs.clone()],
+                                    lhs.clone(),
+                                    None,
+                                ));
+                                TypecheckAnswer::fail(expr)
+                            }
+                            (Some(lhs), Some(rhs)) => {
+                                type_errors.push(ValidationError::expected_one_of_types(
+                                    expr_ty_arg1.source_loc().cloned(),
+                                    self.policy_id.clone(),
+                                    expected_types.clone(),
+                                    lhs.clone(),
+                                    None,
+                                ));
+                                type_errors.push(ValidationError::expected_one_of_types(
+                                    expr_ty_arg2.source_loc().cloned(),
+                                    self.policy_id.clone(),
+                                    expected_types,
+                                    rhs.clone(),
+                                    None,
+                                ));
+                                TypecheckAnswer::fail(expr)
+                            }
+                            _ => TypecheckAnswer::fail(expr),
+                        }
                     })
                 })
             }
diff --git a/cedar-policy-validator/src/types.rs b/cedar-policy-validator/src/types.rs
index 74c2efe9..e9c689da 100644
--- a/cedar-policy-validator/src/types.rs
+++ b/cedar-policy-validator/src/types.rs
@@ -660,6 +660,15 @@ impl Type {
             },
         }
     }
+
+    pub(crate) fn support_operator_overloading(&self) -> bool {
+        match self {
+            Self::ExtensionType { name } => {
+                Extensions::types_with_operator_overloading().contains(name)
+            }
+            _ => false,
+        }
+    }
 }
 
 impl Display for Type {
diff --git a/cedar-policy/CHANGELOG.md b/cedar-policy/CHANGELOG.md
index 621a0da7..472ccc76 100644
--- a/cedar-policy/CHANGELOG.md
+++ b/cedar-policy/CHANGELOG.md
@@ -16,14 +16,17 @@ Cedar Language Version: TBD
 
 - Added protobuf schemas and (de)serialization code using on `prost` crate behind the experimental `protobufs` flag.
 
+## [4.2.2] - Coming soon
+Cedar Language version: 4.1
+
 ### Changed
 
 - The error associated with parsing a non-existent extension function additionally
   includes a suggestion based on available extension functions (#1280, resolving #332).
 - The error associated with parsing a non-existent extension method additionally
   includes a suggestion based on available extension methods (#1289, resolving #246).
-- Extract action graph inversion from `CoreSchema` to `ValidatorSchema` instantiation
-  to improve schema validation speeds. (#1290, as part of resolving #1285)
+- Improved entity validation speed, particularly for large schemas with many
+  actions (#1290 and #1296, resolving #1285)
 
 ### Fixed
 
@@ -758,7 +761,8 @@ Cedar Language Version: 2.0
 Cedar Language Version: 2.0
 - Initial release of `cedar-policy`.
 
-[Unreleased]: https://github.com/cedar-policy/cedar/compare/v4.2.1...main
+[Unreleased]: https://github.com/cedar-policy/cedar/compare/v4.2.2...main
+[4.2.1]: https://github.com/cedar-policy/cedar/compare/v4.2.1...v4.2.2
 [4.2.1]: https://github.com/cedar-policy/cedar/compare/v4.2.0...v4.2.1
 [4.2.0]: https://github.com/cedar-policy/cedar/compare/v4.1.0...v4.2.0
 [4.1.0]: https://github.com/cedar-policy/cedar/compare/v4.0.0...v4.1.0
diff --git a/cedar-policy/Cargo.toml b/cedar-policy/Cargo.toml
index aa261d2d..281b173d 100644
--- a/cedar-policy/Cargo.toml
+++ b/cedar-policy/Cargo.toml
@@ -20,7 +20,7 @@ serde_json = "1.0"
 lalrpop-util = { version = "0.22.0", features = ["lexer"] }
 itertools = "0.13"
 miette = "7.1.0"
-thiserror = "1.0"
+thiserror = "2.0"
 smol_str = { version = "0.3", features = ["serde"] }
 dhat = { version = "0.3.2", optional = true }
 serde_with = "3.3.0"
diff --git a/cedar-policy/src/api.rs b/cedar-policy/src/api.rs
index 3913b44b..0844387e 100644
--- a/cedar-policy/src/api.rs
+++ b/cedar-policy/src/api.rs
@@ -38,9 +38,9 @@ pub use err::*;
 
 pub use ast::Effect;
 pub use authorizer::Decision;
-use cedar_policy_core::ast;
 #[cfg(feature = "partial-eval")]
 use cedar_policy_core::ast::BorrowedRestrictedExpr;
+use cedar_policy_core::ast::{self, RestrictedExpr};
 use cedar_policy_core::authorizer;
 use cedar_policy_core::entities::{ContextSchema, Dereference};
 use cedar_policy_core::est::{self, TemplateLink};
@@ -58,6 +58,7 @@ use smol_str::SmolStr;
 use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
 use std::io::Read;
 use std::str::FromStr;
+use std::sync::Arc;
 
 // PANIC SAFETY: `CARGO_PKG_VERSION` should return a valid SemVer version string
 #[allow(clippy::unwrap_used)]
@@ -407,7 +408,7 @@ impl Entities {
     ) -> Result<Self, EntitiesError> {
         Ok(Self(
             self.0.add_entities(
-                entities.into_iter().map(|e| e.0),
+                entities.into_iter().map(|e| Arc::new(e.0)),
                 schema
                     .map(|s| cedar_policy_validator::CoreSchema::new(&s.0))
                     .as_ref(),
@@ -446,7 +447,7 @@ impl Entities {
             Extensions::all_available(),
             cedar_policy_core::entities::TCComputation::ComputeNow,
         );
-        let new_entities = eparser.iter_from_json_str(json)?;
+        let new_entities = eparser.iter_from_json_str(json)?.map(Arc::new);
         Ok(Self(self.0.add_entities(
             new_entities,
             schema.as_ref(),
@@ -484,7 +485,7 @@ impl Entities {
             Extensions::all_available(),
             cedar_policy_core::entities::TCComputation::ComputeNow,
         );
-        let new_entities = eparser.iter_from_json_value(json)?;
+        let new_entities = eparser.iter_from_json_value(json)?.map(Arc::new);
         Ok(Self(self.0.add_entities(
             new_entities,
             schema.as_ref(),
@@ -523,7 +524,7 @@ impl Entities {
             Extensions::all_available(),
             cedar_policy_core::entities::TCComputation::ComputeNow,
         );
-        let new_entities = eparser.iter_from_json_file(json)?;
+        let new_entities = eparser.iter_from_json_file(json)?.map(Arc::new);
         Ok(Self(self.0.add_entities(
             new_entities,
             schema.as_ref(),
@@ -580,7 +581,7 @@ impl Entities {
     /// # let entity = entities.get(&euid).unwrap();
     /// # assert_eq!(entity.attr("age").unwrap().unwrap(), EvalResult::Long(19));
     /// # let ip = entity.attr("ip_addr").unwrap().unwrap();
-    /// # assert_eq!(ip, EvalResult::ExtensionValue("10.0.1.101/32".to_string()));
+    /// # assert_eq!(ip, EvalResult::ExtensionValue("ip(\"10.0.1.101\")".to_string()));
     /// ```
     pub fn from_json_str(json: &str, schema: Option<&Schema>) -> Result<Self, EntitiesError> {
         let schema = schema.map(|s| cedar_policy_validator::CoreSchema::new(&s.0));
@@ -3956,7 +3957,9 @@ impl From<ast::Value> for EvalResult {
                     .map(|(k, v)| (k.to_string(), v.clone().into()))
                     .collect(),
             )),
-            ast::ValueKind::ExtensionValue(ev) => Self::ExtensionValue(ev.to_string()),
+            ast::ValueKind::ExtensionValue(ev) => {
+                Self::ExtensionValue(RestrictedExpr::from(ev.as_ref().clone()).to_string())
+            }
         }
     }
 }


Test patch:
diff --git a/cedar-policy-validator/src/typecheck/test/expr.rs b/cedar-policy-validator/src/typecheck/test/expr.rs
index ea49baf4..c232de3c 100644
--- a/cedar-policy-validator/src/typecheck/test/expr.rs
+++ b/cedar-policy-validator/src/typecheck/test/expr.rs
@@ -20,7 +20,11 @@
 
 use std::{str::FromStr, vec};
 
-use cedar_policy_core::ast::{BinaryOp, EntityUID, Expr, PatternElem, SlotId, Var};
+use cedar_policy_core::{
+    ast::{BinaryOp, EntityUID, Expr, Name, PatternElem, SlotId, Value, Var},
+    extensions::Extensions,
+};
+use itertools::Itertools;
 use serde_json::json;
 use smol_str::SmolStr;
 
@@ -1010,38 +1014,211 @@ 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 = std::iter::once(Type::primitive_long())
+        .chain(
+            Extensions::types_with_operator_overloading()
+                .into_iter()
+                .map(Type::extension),
+        )
+        .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_type(
+            ValidationError::expected_one_of_types(
                 get_loc(src, "true"),
                 expr_id_placeholder(),
-                Type::primitive_long(),
+                expected_types.clone(),
                 Type::singleton_boolean(true),
                 None,
             ),
-            ValidationError::expected_type(
+            ValidationError::expected_one_of_types(
                 get_loc(src, "false"),
                 expr_id_placeholder(),
-                Type::primitive_long(),
+                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.clone(),
+                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,
+        )],
+    );
 }
 
 #[test]
diff --git a/cedar-policy-validator/src/typecheck/test/extensions.rs b/cedar-policy-validator/src/typecheck/test/extensions.rs
index 306ea3f1..b58c660f 100644
--- a/cedar-policy-validator/src/typecheck/test/extensions.rs
+++ b/cedar-policy-validator/src/typecheck/test/extensions.rs
@@ -179,3 +179,167 @@ fn decimal_extension_typecheck_fails() {
         )
     );
 }
+
+#[test]
+#[cfg(feature = "datetime")]
+fn datetime_extension_typechecks() {
+    use cedar_policy_core::ast::Name;
+
+    let datetime_name =
+        Name::parse_unqualified_name("datetime").expect("should be a valid identifier");
+    let duration_name =
+        Name::parse_unqualified_name("duration").expect("should be a valid identifier");
+
+    let expr = Expr::from_str("datetime(\"2024-10-28\")").expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::extension(datetime_name.clone()));
+    let expr = Expr::from_str(r#"datetime("2024-10-28").offset(duration("1d"))"#)
+        .expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::extension(datetime_name.clone()));
+    let expr = Expr::from_str(r#"datetime("2024-10-28").durationSince(datetime("2024-10-28"))"#)
+        .expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::extension(duration_name.clone()));
+    let expr =
+        Expr::from_str(r#"datetime("2024-10-28").toDate()"#).expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::extension(datetime_name.clone()));
+    let expr =
+        Expr::from_str(r#"datetime("2024-10-28").toTime()"#).expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::extension(duration_name.clone()));
+    let expr = Expr::from_str(r#"datetime("2024-10-28") < datetime("2024-10-28")"#)
+        .expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::primitive_boolean());
+    let expr = Expr::from_str(r#"datetime("2024-10-28") <= datetime("2024-10-28")"#)
+        .expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::primitive_boolean());
+    let expr = Expr::from_str(r#"datetime("2024-10-28") > datetime("2024-10-28")"#)
+        .expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::primitive_boolean());
+    let expr = Expr::from_str(r#"datetime("2024-10-28") >= datetime("2024-10-28")"#)
+        .expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::primitive_boolean());
+    let expr = Expr::from_str(r#"datetime("2024-10-28") == datetime("2024-10-28")"#)
+        .expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::primitive_boolean());
+    let expr = Expr::from_str(r#"datetime("2024-10-28") != datetime("2024-10-28")"#)
+        .expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::primitive_boolean());
+
+    let expr = Expr::from_str("duration(\"1h\")").expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::extension(duration_name.clone()));
+    let expr =
+        Expr::from_str(r#"duration("1h").toMilliseconds()"#).expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::primitive_long());
+    let expr = Expr::from_str(r#"duration("1h").toSeconds()"#).expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::primitive_long());
+    let expr = Expr::from_str(r#"duration("1h").toMinutes()"#).expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::primitive_long());
+    let expr = Expr::from_str(r#"duration("1h").toHours()"#).expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::primitive_long());
+    let expr = Expr::from_str(r#"duration("1h").toDays()"#).expect("parsing should succeed");
+    assert_typechecks_empty_schema(expr, Type::primitive_long());
+}
+
+#[test]
+#[cfg(feature = "datetime")]
+fn datetime_extension_typecheck_fails() {
+    use cedar_policy_core::ast::Name;
+
+    let datetime_name =
+        Name::parse_unqualified_name("datetime").expect("should be a valid identifier");
+    let duration_name =
+        Name::parse_unqualified_name("duration").expect("should be a valid identifier");
+
+    let src = "datetime(3)";
+    let expr = Expr::from_str(src).expect("parsing should succeed");
+    let errors = assert_typecheck_fails_empty_schema(expr, Type::extension(datetime_name.clone()));
+    let type_error = assert_exactly_one_diagnostic(errors);
+    assert_eq!(
+        type_error,
+        ValidationError::expected_type(
+            get_loc(src, "3"),
+            expr_id_placeholder(),
+            Type::primitive_string(),
+            Type::primitive_long(),
+            None,
+        )
+    );
+    let src = "datetime(\"foo\")";
+    let expr = Expr::from_str(src).expect("parsing should succeed");
+    let errors =
+        assert_typecheck_fails_empty_schema(expr.clone(), Type::extension(datetime_name.clone()));
+    let type_error = assert_exactly_one_diagnostic(errors);
+    assert_eq!(
+        type_error,
+        ValidationError::function_argument_validation(
+            get_loc(src, src),
+            expr_id_placeholder(),
+            "Failed to parse as a datetime value: `\"foo\"`".into(),
+        )
+    );
+
+    let src = "duration(3)";
+    let expr = Expr::from_str(src).expect("parsing should succeed");
+    let errors = assert_typecheck_fails_empty_schema(expr, Type::extension(duration_name.clone()));
+    let type_error = assert_exactly_one_diagnostic(errors);
+    assert_eq!(
+        type_error,
+        ValidationError::expected_type(
+            get_loc(src, "3"),
+            expr_id_placeholder(),
+            Type::primitive_string(),
+            Type::primitive_long(),
+            None,
+        )
+    );
+    let src = "duration(\"foo\")";
+    let expr = Expr::from_str(src).expect("parsing should succeed");
+    let errors =
+        assert_typecheck_fails_empty_schema(expr.clone(), Type::extension(duration_name.clone()));
+    let type_error = assert_exactly_one_diagnostic(errors);
+    assert_eq!(
+        type_error,
+        ValidationError::function_argument_validation(
+            get_loc(src, src),
+            expr_id_placeholder(),
+            "Failed to parse as a duration value: `\"foo\"`".into(),
+        )
+    );
+
+    let src = r#"datetime("2024-10-28").offset(3, 4)"#;
+    let expr = Expr::from_str(src).expect("parsing should succeed");
+    let errors =
+        assert_typecheck_fails_empty_schema(expr.clone(), Type::extension(datetime_name.clone()));
+    let type_error = assert_exactly_one_diagnostic(errors);
+    assert_eq!(
+        type_error,
+        ValidationError::wrong_number_args(get_loc(src, src), expr_id_placeholder(), 2, 3,)
+    );
+    let src = r#"datetime("2024-10-28").offset(9)"#;
+    let expr = Expr::from_str(src).expect("parsing should succeed");
+    let errors = assert_typecheck_fails_empty_schema(expr, Type::extension(datetime_name.clone()));
+    let type_error = assert_exactly_one_diagnostic(errors);
+    assert_eq!(
+        type_error,
+        ValidationError::expected_type(
+            get_loc(src, "9"),
+            expr_id_placeholder(),
+            Type::extension(duration_name.clone()),
+            Type::primitive_long(),
+            None,
+        )
+    );
+
+    let src = r#"datetime("2024-10-29") < duration("1h")"#;
+    let expr = Expr::from_str(src).expect("parsing should succeed");
+    let errors = assert_typecheck_fails_empty_schema(expr, Type::primitive_boolean());
+    let type_error = assert_exactly_one_diagnostic(errors);
+    assert_eq!(
+        type_error,
+        ValidationError::expected_type(
+            get_loc(src, r#"duration("1h")"#),
+            expr_id_placeholder(),
+            Type::extension(datetime_name),
+            Type::extension(duration_name),
+            None,
+        )
+    );
+}
diff --git a/cedar-policy-validator/src/typecheck/test/namespace.rs b/cedar-policy-validator/src/typecheck/test/namespace.rs
index da53b3c5..1766e0f0 100644
--- a/cedar-policy-validator/src/typecheck/test/namespace.rs
+++ b/cedar-policy-validator/src/typecheck/test/namespace.rs
@@ -145,10 +145,10 @@ fn namespaced_entity_can_type_error() {
     let type_error = assert_exactly_one_diagnostic(errors);
     assert_eq!(
         type_error,
-        ValidationError::expected_type(
+        ValidationError::expected_one_of_types(
             get_loc(src, r#"N::S::Foo::"alice""#),
             expr_id_placeholder(),
-            Type::primitive_long(),
+            std::iter::once(Type::primitive_long()),
             Type::named_entity_reference_from_str("N::S::Foo"),
             None,
         )
diff --git a/cedar-policy-validator/src/typecheck/test/partial.rs b/cedar-policy-validator/src/typecheck/test/partial.rs
index 006be880..aee372ab 100644
--- a/cedar-policy-validator/src/typecheck/test/partial.rs
+++ b/cedar-policy-validator/src/typecheck/test/partial.rs
@@ -387,7 +387,7 @@ mod passes_empty_schema {
 }
 
 mod fails_empty_schema {
-    use cedar_policy_core::ast::PolicyID;
+    use cedar_policy_core::{ast::PolicyID, extensions::Extensions};
 
     use crate::types::Type;
 
@@ -402,10 +402,14 @@ mod fails_empty_schema {
         let src = r#"permit(principal, action, resource) when { principal.foo > "a" };"#;
         assert_typecheck_fails_empty_schema(
             parse_policy(None, src).unwrap(),
-            [ValidationError::expected_type(
+            [ValidationError::expected_one_of_types(
                 get_loc(src, r#""a""#),
                 PolicyID::from_string("policy0"),
-                Type::primitive_long(),
+                std::iter::once(Type::primitive_long()).chain(
+                    Extensions::types_with_operator_overloading()
+                        .into_iter()
+                        .map(Type::extension),
+                ),
                 Type::primitive_string(),
                 None,
             )],
@@ -634,7 +638,7 @@ mod passes_partial_schema {
 }
 
 mod fail_partial_schema {
-    use cedar_policy_core::ast::PolicyID;
+    use cedar_policy_core::{ast::PolicyID, extensions::Extensions};
 
     use super::*;
     use crate::validation_errors::{LubContext, LubHelp};
@@ -646,10 +650,14 @@ mod fail_partial_schema {
         let src = r#"permit(principal == User::"alice", action, resource) when { principal.name > principal.unknown };"#;
         assert_typecheck_fails_partial_schema(
             parse_policy(None, src).unwrap(),
-            [ValidationError::expected_type(
+            [ValidationError::expected_one_of_types(
                 get_loc(src, "principal.name"),
                 PolicyID::from_string("policy0"),
-                Type::primitive_long(),
+                std::iter::once(Type::primitive_long()).chain(
+                    Extensions::types_with_operator_overloading()
+                        .into_iter()
+                        .map(Type::extension),
+                ),
                 Type::primitive_string(),
                 None,
             )],
diff --git a/cedar-policy-validator/src/typecheck/test/policy.rs b/cedar-policy-validator/src/typecheck/test/policy.rs
index 3ee6059f..9ee21416 100644
--- a/cedar-policy-validator/src/typecheck/test/policy.rs
+++ b/cedar-policy-validator/src/typecheck/test/policy.rs
@@ -841,10 +841,10 @@ fn type_error_is_not_reported_for_every_cross_product_element() {
     let error = assert_exactly_one_diagnostic(errors);
     assert_eq!(
         error,
-        ValidationError::expected_type(
+        ValidationError::expected_one_of_types(
             get_loc(src, "true"),
             PolicyID::from_string("0"),
-            Type::primitive_long(),
+            std::iter::once(Type::primitive_long()),
             Type::True,
             None,
         )
diff --git a/cedar-policy/src/tests.rs b/cedar-policy/src/tests.rs
index 3740ebfa..5986a356 100644
--- a/cedar-policy/src/tests.rs
+++ b/cedar-policy/src/tests.rs
@@ -2334,15 +2334,15 @@ mod schema_based_parsing_tests {
         };
         assert_matches!(
             parsed.attr("home_ip"),
-            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == "222.222.222.101/32"
+            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == r#"ip("222.222.222.101")"#
         );
         assert_matches!(
             parsed.attr("work_ip"),
-            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == "2.2.2.0/24"
+            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == r#"ip("2.2.2.0/24")"#
         );
         assert_matches!(
             parsed.attr("trust_score"),
-            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == "5.7000"
+            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == r#"decimal("5.7")"#
         );
 
         // simple type mismatch with expected type
@@ -2817,15 +2817,15 @@ mod schema_based_parsing_tests {
         };
         assert_matches!(
             parsed.attr("home_ip"),
-            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == "222.222.222.101/32"
+            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == r#"ip("222.222.222.101")"#
         );
         assert_matches!(
             parsed.attr("work_ip"),
-            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == "2.2.2.0/24"
+            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == r#"ip("2.2.2.0/24")"#
         );
         assert_matches!(
             parsed.attr("trust_score"),
-            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == "5.7000"
+            Some(Ok(EvalResult::ExtensionValue(ev))) if &ev == r#"decimal("5.7")"#
         );
 
         // simple type mismatch with expected type
@@ -4689,7 +4689,7 @@ mod decimal_ip_constructors {
     fn expr_ip() {
         let ip = Expression::new_ip("10.10.10.10");
         assert_matches!(evaluate_empty(&ip),
-                Ok(EvalResult::ExtensionValue(o)) => assert_eq!(&o, "10.10.10.10/32")
+                Ok(EvalResult::ExtensionValue(o)) => assert_eq!(&o, r#"ip("10.10.10.10")"#)
         );
     }
 
@@ -4697,7 +4697,7 @@ mod decimal_ip_constructors {
     fn expr_ip_network() {
         let ip = Expression::new_ip("10.10.10.10/16");
         assert_matches!(evaluate_empty(&ip),
-            Ok(EvalResult::ExtensionValue(o)) => assert_eq!(&o, "10.10.10.10/16")
+            Ok(EvalResult::ExtensionValue(o)) => assert_eq!(&o, r#"ip("10.10.10.10/16")"#)
         );
     }