Repository: cedar-policy/cedar
Pull request #1697 (2025-07-09 21:13:14)

Allow multiple extension function arguments in JSON entity format
## Description of changes

## Issue #, if available

## Checklist for requesting a review

The change in this PR is (choose one, and delete the other options):

- [x] A backwards-compatible change requiring a minor version bump to `cedar-policy` (e.g., addition of a new API).

I confirm that this PR (choose one, and delete the other options):

- [x] Updates the "Unreleased" section of the CHANGELOG with a description of my change (required for major/minor version bumps).

I confirm that [`cedar-spec`](https://github.com/cedar-policy/cedar-spec) (choose one, and delete the other options):

- [x] Does not require updates because my change does not impact the Cedar formal model or DRT infrastructure.

I confirm that [`docs.cedarpolicy.com`](https://docs.cedarpolicy.com/) (choose one, and delete the other options):

- [x] Requires updates, and I have made / will make these updates myself. (Please include in your description a timeline or link to the relevant PR in [`cedar-docs`](https://github.com/cedar-policy/cedar-docs). PRs should be targeted at a `staging-X.Y` branch, not `main`.)


github-actions[bot]: 
# Coverage Report
Head Commit: ```57ebbf6cb93a1aaf9fd10dd8fb515650e473492d```

Base Commit: ```aff4fbafce2cebd0ec8b1919d4a5bd03d8022aec```

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

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

**Actual coverage:** 22.83%

**Status:** FAILED :x:

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

| File | Status | Covered | Coverage | Missed Lines |
|:-----|:------:|:-------:|---------:|:-------------|
cedar-policy-core/src/entities/json/value.rs | :red_circle: | 21/63 | 33.33% | 160, 162-172, 365, 420-422, 426, 430, 432-437, 439-440, 524, 526-530, 573, 576-577, 581, 584-588, 638
cedar-policy-core/src/est/expr.rs | :red_circle: | 0/29 | 0.00% | 1128, 1153, 1156-1158, 1160, 1162-1163, 1167-1168, 1171-1176, 1178, 1181-1182, 1185-1191, 1193, 1196-1197
</details>

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

**Actual coverage:** 82.43%

**Status:** PASSED :white_check_mark:

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

| Package | Status | Covered | Coverage | Base Coverage |
|:--------|:------:|:-------:|---------:|--------------:|
cedar-language-server | :green_circle: | 4459/5159 | 86.43% | 86.43%
cedar-policy | :red_circle: | 3098/4513 | 68.65% | 68.65%
cedar-policy-cli | :red_circle: | 675/1054 | 64.04% | 64.04%
cedar-policy-core | :green_circle: | 19767/23227 | 85.10% | 85.30%
cedar-policy-formatter | :green_circle: | 928/1110 | 83.60% | 83.60%
cedar-wasm | :red_circle: | 0/28 | 0.00% | 0.00%
</details>

github-actions[bot]: 
# Coverage Report
Head Commit: ```74800cf0b0ea4649b56b38abc589412f4777d00f```

Base Commit: ```a57d77681ef1a1c053dd0df4b8a0492253ea409d```

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

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

**Actual coverage:** 44.59%

**Status:** FAILED :x:

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

| File | Status | Covered | Coverage | Missed Lines |
|:-----|:------:|:-------:|---------:|:-------------|
cedar-language-server/src/policy/types/cedar/method.rs | :green_circle: | 1/1 | 100.00% | 
cedar-policy-core/src/ast/extension.rs | :green_circle: | 2/2 | 100.00% | 
cedar-policy-core/src/entities/json/err.rs | :red_circle: | 0/11 | 0.00% | 197-207
cedar-policy-core/src/entities/json/value.rs | :red_circle: | 65/112 | 58.04% | 172, 420-422, 427, 431, 433-438, 440-441, 525, 527-531, 574, 577-578, 582, 585-589, 639, 707, 713-716, 718-719, 723-727, 770, 773-776
cedar-policy-core/src/est/expr.rs | :red_circle: | 0/29 | 0.00% | 1128, 1153, 1156-1158, 1160, 1162-1163, 1167-1168, 1171-1176, 1178, 1181-1182, 1185-1191, 1193, 1196-1197
cedar-policy-core/src/extensions.rs | :green_circle: | 2/2 | 100.00% | 
</details>

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

**Actual coverage:** 82.40%

**Status:** PASSED :white_check_mark:

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

| Package | Status | Covered | Coverage | Base Coverage |
|:--------|:------:|:-------:|---------:|--------------:|
cedar-language-server | :green_circle: | 4459/5159 | 86.43% | --
cedar-policy | :red_circle: | 3098/4535 | 68.31% | --
cedar-policy-cli | :red_circle: | 675/1054 | 64.04% | --
cedar-policy-core | :green_circle: | 19781/23236 | 85.13% | --
cedar-policy-formatter | :green_circle: | 928/1110 | 83.60% | --
cedar-wasm | :red_circle: | 0/28 | 0.00% | --
</details>

github-actions[bot]: 
# Coverage Report
Head Commit: ```99246422832f84682a2b34d27396f6ce7c66b624```

Base Commit: ```a57d77681ef1a1c053dd0df4b8a0492253ea409d```

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

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

**Actual coverage:** 44.59%

**Status:** FAILED :x:

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

| File | Status | Covered | Coverage | Missed Lines |
|:-----|:------:|:-------:|---------:|:-------------|
cedar-language-server/src/policy/types/cedar/method.rs | :green_circle: | 1/1 | 100.00% | 
cedar-policy-core/src/ast/extension.rs | :green_circle: | 2/2 | 100.00% | 
cedar-policy-core/src/entities/json/err.rs | :red_circle: | 0/11 | 0.00% | 197-207
cedar-policy-core/src/entities/json/value.rs | :red_circle: | 65/112 | 58.04% | 172, 420-422, 427, 431, 433-438, 440-441, 525, 527-531, 574, 577-578, 582, 585-589, 639, 707, 713-716, 718-719, 723-727, 770, 773-776
cedar-policy-core/src/est/expr.rs | :red_circle: | 0/29 | 0.00% | 1128, 1153, 1156-1158, 1160, 1162-1163, 1167-1168, 1171-1176, 1178, 1181-1182, 1185-1191, 1193, 1196-1197
cedar-policy-core/src/extensions.rs | :green_circle: | 2/2 | 100.00% | 
</details>

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

**Actual coverage:** 82.40%

**Status:** PASSED :white_check_mark:

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

| Package | Status | Covered | Coverage | Base Coverage |
|:--------|:------:|:-------:|---------:|--------------:|
cedar-language-server | :green_circle: | 4459/5159 | 86.43% | --
cedar-policy | :red_circle: | 3098/4535 | 68.31% | --
cedar-policy-cli | :red_circle: | 675/1054 | 64.04% | --
cedar-policy-core | :green_circle: | 19781/23236 | 85.13% | --
cedar-policy-formatter | :green_circle: | 928/1110 | 83.60% | --
cedar-wasm | :red_circle: | 0/28 | 0.00% | --
</details>

shaobo-he-aws: > Also, entity parsing code is complicated so I'd like some tests hitting the uncovered lines

A lot of uncovered lines are actually covered by DRT. In other words, this PR does not introduce them. I added more tests to cover entity parsing, the core logic this PR targets at.
github-actions[bot]: 
# Coverage Report
Head Commit: ```bb2e7ae24babba82b4fb68aa024a863400729e79```

Base Commit: ```a57d77681ef1a1c053dd0df4b8a0492253ea409d```

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

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

**Actual coverage:** 54.78%

**Status:** FAILED :x:

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

| File | Status | Covered | Coverage | Missed Lines |
|:-----|:------:|:-------:|---------:|:-------------|
cedar-language-server/src/policy/types/cedar/method.rs | :green_circle: | 1/1 | 100.00% | 
cedar-policy-core/src/ast/extension.rs | :green_circle: | 2/2 | 100.00% | 
cedar-policy-core/src/entities/json/err.rs | :green_circle: | 11/11 | 100.00% | 
cedar-policy-core/src/entities/json/value.rs | :red_circle: | 70/112 | 62.50% | 172, 420-422, 427, 431, 433-438, 440-441, 525, 527-531, 574, 577-578, 582, 585-589, 639, 707, 713-716, 718-719, 770, 773-776
cedar-policy-core/src/est/expr.rs | :red_circle: | 0/29 | 0.00% | 1128, 1153, 1156-1158, 1160, 1162-1163, 1167-1168, 1171-1176, 1178, 1181-1182, 1185-1191, 1193, 1196-1197
cedar-policy-core/src/extensions.rs | :green_circle: | 2/2 | 100.00% | 
</details>

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

**Actual coverage:** 82.45%

**Status:** PASSED :white_check_mark:

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

| Package | Status | Covered | Coverage | Base Coverage |
|:--------|:------:|:-------:|---------:|--------------:|
cedar-language-server | :green_circle: | 4459/5159 | 86.43% | --
cedar-policy | :red_circle: | 3098/4535 | 68.31% | --
cedar-policy-cli | :red_circle: | 675/1054 | 64.04% | --
cedar-policy-core | :green_circle: | 19797/23236 | 85.20% | --
cedar-policy-formatter | :green_circle: | 928/1110 | 83.60% | --
cedar-wasm | :red_circle: | 0/28 | 0.00% | --
</details>


Patch:
diff --git a/cedar-language-server/src/policy/types/cedar/method.rs b/cedar-language-server/src/policy/types/cedar/method.rs
index b10a3099..817f926f 100644
--- a/cedar-language-server/src/policy/types/cedar/method.rs
+++ b/cedar-language-server/src/policy/types/cedar/method.rs
@@ -124,7 +124,7 @@ impl MethodInfo {
 
     pub(crate) fn from_extension(ext: &Extension) -> Vec<Self> {
         ext.funcs()
-            .filter(|f| !f.is_constructor())
+            .filter(|f| !f.is_single_arg_constructor())
             .map(Self::from)
             .collect()
     }
diff --git a/cedar-policy-core/src/ast/extension.rs b/cedar-policy-core/src/ast/extension.rs
index a02f7ea6..435af22d 100644
--- a/cedar-policy-core/src/ast/extension.rs
+++ b/cedar-policy-core/src/ast/extension.rs
@@ -325,15 +325,16 @@ impl ExtensionFunction {
         &self.arg_types
     }
 
-    /// Returns `true` if this function is considered a "constructor".
+    /// Returns `true` if this function is considered a single argument
+    /// constructor.
     ///
-    /// Currently, the only impact of this is that non-constructors are not
-    /// accessible in the JSON format (entities/json.rs).
-    pub fn is_constructor(&self) -> bool {
+    /// Only functions satisfying this predicate can have their names implicit
+    /// during schema-based entity parsing
+    pub fn is_single_arg_constructor(&self) -> bool {
         // return type is an extension type
         matches!(self.return_type(), Some(SchemaType::Extension { .. }))
-        // no argument is an extension type
-        && !self.arg_types().iter().any(|ty| matches!(ty, SchemaType::Extension { .. }))
+        // the only argument is a string
+        && matches!(self.arg_types(), [SchemaType::String])
     }
 
     /// Call the `ExtensionFunction` with the given args
diff --git a/cedar-policy-core/src/entities.rs b/cedar-policy-core/src/entities.rs
index 11e93f6e..9a197ef0 100644
--- a/cedar-policy-core/src/entities.rs
+++ b/cedar-policy-core/src/entities.rs
@@ -31,8 +31,8 @@ use json::err::JsonSerializationError;
 
 pub use json::{
     AllEntitiesNoAttrsSchema, AttributeType, CedarValueJson, ContextJsonParser, ContextSchema,
-    EntityJson, EntityJsonParser, EntityTypeDescription, EntityUidJson, FnAndArg, NoEntitiesSchema,
-    NoStaticContext, Schema, SchemaType, TypeAndId,
+    EntityJson, EntityJsonParser, EntityTypeDescription, EntityUidJson, FnAndArgs,
+    NoEntitiesSchema, NoStaticContext, Schema, SchemaType, TypeAndId,
 };
 
 use conformance::EntitySchemaConformanceChecker;
@@ -2104,6 +2104,50 @@ mod json_parsing_tests {
             );
         });
     }
+
+    #[test]
+    fn multi_arg_ext_func_calls() {
+        let eparser: EntityJsonParser<'_, '_> =
+            EntityJsonParser::new(None, Extensions::all_available(), TCComputation::ComputeNow);
+
+        let json = serde_json::json!(
+            {
+                "uid": { "type": "User", "id": "alice "},
+                    "attrs": {
+                        "time": { "__extn": { "fn": "offset", "args": [{ "__extn": { "fn": "datetime", "arg": "1970-01-01" }}, { "__extn": { "fn": "duration", "arg": "1h" } }]}}
+                    },
+                    "parents": []
+            }
+        );
+
+        assert_matches!(eparser.single_from_json_value(json), Ok(entity) => {
+            assert_matches!(entity.get("time"), Some(PartialValue::Value(Value { value: ValueKind::ExtensionValue(v), .. })) => {
+                assert_eq!(v.func, "offset".parse().unwrap());
+                assert_eq!(v.args[0].to_string(), r#"datetime("1970-01-01")"#);
+                assert_eq!(v.args[1].to_string(), r#"duration("3600000ms")"#);
+            });
+        });
+
+        // It appears that additional attributes are simply ignored
+        // PR #1697 doesn't alter this behavior
+        let json = serde_json::json!(
+            {
+                "uid": { "type": "User", "id": "alice "},
+                    "attrs": {
+                        "time": { "__extn": { "fn": "offset", "args": [{ "__extn": { "fn": "datetime", "arg": "1970-01-01" }}, { "__extn": { "fn": "duration", "arg": "1h" } }], "aaargs": 42}}
+                    },
+                    "parents": []
+            }
+        );
+
+        assert_matches!(eparser.single_from_json_value(json), Ok(entity) => {
+            assert_matches!(entity.get("time"), Some(PartialValue::Value(Value { value: ValueKind::ExtensionValue(v), .. })) => {
+                assert_eq!(v.func, "offset".parse().unwrap());
+                assert_eq!(v.args[0].to_string(), r#"datetime("1970-01-01")"#);
+                assert_eq!(v.args[1].to_string(), r#"duration("3600000ms")"#);
+            });
+        });
+    }
 }
 
 // PANIC SAFETY: Unit Test Code
@@ -2514,6 +2558,9 @@ mod schema_based_parsing_tests {
                     .collect(),
                     open_attrs: false,
                 }),
+                "start_date" => Some(SchemaType::Extension {
+                    name: Name::parse_unqualified_name("datetime").expect("valid"),
+                }),
                 _ => None,
             }
         }
@@ -2576,7 +2623,11 @@ mod schema_based_parsing_tests {
                         "home_ip": "222.222.222.101",
                         "work_ip": { "fn": "ip", "arg": "2.2.2.0/24" },
                         "trust_score": "5.7",
-                        "tricky": { "type": "Employee", "id": "34FB87" }
+                        "tricky": { "type": "Employee", "id": "34FB87" },
+                        "start_date": { "fn": "offset", "args": [
+                            {"fn": "datetime", "arg": "1970-01-01"},
+                            {"fn": "duration", "arg": "1h"}
+                        ]}
                     },
                     "parents": [],
                     "tags": {
diff --git a/cedar-policy-core/src/entities/json/err.rs b/cedar-policy-core/src/entities/json/err.rs
index b649322a..3aaef8ec 100644
--- a/cedar-policy-core/src/entities/json/err.rs
+++ b/cedar-policy-core/src/entities/json/err.rs
@@ -23,11 +23,12 @@ use crate::ast::{
 };
 use crate::entities::conformance::err::EntitySchemaConformanceError;
 use crate::entities::{Name, ReservedNameError};
+use crate::extensions::ExtensionFunctionLookupError;
 use crate::parser::err::ParseErrors;
 use either::Either;
 use itertools::Itertools;
 use miette::Diagnostic;
-use smol_str::SmolStr;
+use smol_str::{SmolStr, ToSmolStr};
 use thiserror::Error;
 
 /// Escape kind
@@ -85,6 +86,15 @@ pub enum JsonDeserializationError {
     #[error(transparent)]
     #[diagnostic(transparent)]
     MissingImpliedConstructor(MissingImpliedConstructor),
+    /// Incorrect number of arguments of an extension function are provided
+    /// during schema-based parsing
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    IncorrectNumOfArguments(IncorrectNumOfArguments),
+    /// Failed to look up an extension function
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    FailedExtensionFunctionLookup(#[from] ExtensionFunctionLookupError),
     /// The same key appears two or more times in a single record
     #[error(transparent)]
     #[diagnostic(transparent)]
@@ -170,16 +180,6 @@ impl JsonDeserializationError {
         })
     }
 
-    pub(crate) fn expected_extn_value(
-        ctx: JsonDeserializationErrorContext,
-        got: Either<serde_json::Value, Expr>,
-    ) -> Self {
-        Self::ExpectedExtnValue(ExpectedExtnValue {
-            ctx: Box::new(ctx),
-            got: Box::new(got),
-        })
-    }
-
     pub(crate) fn action_parent_is_not_action(uid: EntityUID, parent: EntityUID) -> Self {
         Self::ActionParentIsNotAction(ActionParentIsNotAction { uid, parent })
     }
@@ -194,6 +194,18 @@ impl JsonDeserializationError {
         })
     }
 
+    pub(crate) fn incorrect_num_of_arguments(
+        expected_arg_num: usize,
+        provided_arg_num: usize,
+        func_name: &str,
+    ) -> Self {
+        Self::IncorrectNumOfArguments(IncorrectNumOfArguments {
+            expected_arg_num,
+            provided_arg_num,
+            func_name: func_name.to_smolstr(),
+        })
+    }
+
     pub(crate) fn duplicate_key(
         ctx: JsonDeserializationErrorContext,
         key: impl Into<SmolStr>,
@@ -289,6 +301,18 @@ pub struct MissingImpliedConstructor {
     return_type: Box<SchemaType>,
 }
 
+#[derive(Debug, Error, Diagnostic)]
+#[error("expected {expected_arg_num} arguments for function {func_name} but {provided_arg_num} arguments are provided")]
+/// Error type for incorrect extension function argument number
+pub struct IncorrectNumOfArguments {
+    /// Expected number of arguments
+    expected_arg_num: usize,
+    /// Provided number of argument
+    provided_arg_num: usize,
+    /// Function name
+    func_name: SmolStr,
+}
+
 #[derive(Debug, Error, Diagnostic)]
 #[error("action `{}` has a non-action parent `{}`", .uid, .parent)]
 #[diagnostic(help("parents of actions need to have type `Action` themselves, perhaps namespaced"))]
@@ -371,11 +395,6 @@ pub enum JsonSerializationError {
     #[error(transparent)]
     #[diagnostic(transparent)]
     ExtnCall0Arguments(ExtnCall0Arguments),
-    /// Extension-function calls with 2 or more arguments are not currently
-    /// supported in our JSON format.
-    #[error(transparent)]
-    #[diagnostic(transparent)]
-    ExtnCall2OrMoreArguments(ExtnCall2OrMoreArguments),
     /// Encountered a `Record` which can't be serialized to JSON because it
     /// contains a key which is reserved as a JSON escape.
     #[error(transparent)]
@@ -392,6 +411,12 @@ pub enum JsonSerializationError {
     #[error(transparent)]
     #[diagnostic(transparent)]
     Residual(Residual),
+    /// Extension-function calls with 2 or more arguments are not currently
+    /// supported in our JSON format.
+    /// Cedar should not throw any error of this variant after #1697
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    ExtnCall2OrMoreArguments(ExtnCall2OrMoreArguments),
 }
 
 impl JsonSerializationError {
@@ -399,10 +424,6 @@ impl JsonSerializationError {
         Self::ExtnCall0Arguments(ExtnCall0Arguments { func })
     }
 
-    pub(crate) fn call_2_or_more_args(func: Name) -> Self {
-        Self::ExtnCall2OrMoreArguments(ExtnCall2OrMoreArguments { func })
-    }
-
     pub(crate) fn reserved_key(key: impl Into<SmolStr>) -> Self {
         Self::ReservedKey(ReservedKey { key: key.into() })
     }
@@ -428,6 +449,7 @@ pub struct ExtnCall0Arguments {
 }
 
 /// Error type for extension functions called w/ 2+ arguments
+/// Cedar should not throw this error after #1697
 #[derive(Debug, Error, Diagnostic)]
 #[error("unsupported call to `{}` with 2 or more arguments", .func)]
 #[diagnostic(help("extension function calls with 2 or more arguments are not currently supported in our JSON format"))]
diff --git a/cedar-policy-core/src/entities/json/value.rs b/cedar-policy-core/src/entities/json/value.rs
index 3ee5568a..81d4e7a4 100644
--- a/cedar-policy-core/src/entities/json/value.rs
+++ b/cedar-policy-core/src/entities/json/value.rs
@@ -104,7 +104,7 @@ pub enum CedarValueJson {
     // `serde(untagged)`
     ExtnEscape {
         /// JSON object containing the extension-constructor call
-        __extn: FnAndArg,
+        __extn: FnAndArgs,
     },
     /// JSON bool => Cedar bool
     Bool(bool),
@@ -151,12 +151,25 @@ impl From<RawCedarValueJson> for CedarValueJson {
                                 {
                                     if let Some(arg) = r.values.get("arg") {
                                         return Self::ExtnEscape {
-                                            __extn: FnAndArg {
+                                            __extn: FnAndArgs::Single {
                                                 ext_fn: fn_name.clone(),
                                                 arg: Box::new(arg.clone().into()),
                                             },
                                         };
                                     }
+                                    if let Some(RawCedarValueJson::Set(args)) = r.values.get("args")
+                                    {
+                                        return Self::ExtnEscape {
+                                            __extn: FnAndArgs::Multi {
+                                                ext_fn: fn_name.clone(),
+                                                args: args
+                                                    .into_iter()
+                                                    .cloned()
+                                                    .map(Into::into)
+                                                    .collect(),
+                                            },
+                                        };
+                                    }
                                 }
                             }
                         }
@@ -318,13 +331,41 @@ impl TryFrom<TypeAndId> for EntityUID {
 #[serde(rename_all = "camelCase")]
 #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
 #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
-pub struct FnAndArg {
-    /// Extension constructor function
-    #[serde(rename = "fn")]
-    #[cfg_attr(feature = "wasm", tsify(type = "string"))]
-    pub(crate) ext_fn: SmolStr,
-    /// Argument to that constructor
-    pub(crate) arg: Box<CedarValueJson>,
+#[serde(untagged)]
+pub enum FnAndArgs {
+    /// Single-argument function
+    Single {
+        /// Extension constructor function
+        #[serde(rename = "fn")]
+        #[cfg_attr(feature = "wasm", tsify(type = "string"))]
+        ext_fn: SmolStr,
+        /// Argument to that constructor
+        arg: Box<CedarValueJson>,
+    },
+    /// Multi-argument function
+    Multi {
+        /// Extension constructor function
+        #[serde(rename = "fn")]
+        #[cfg_attr(feature = "wasm", tsify(type = "string"))]
+        ext_fn: SmolStr,
+        /// Arguments to that constructor
+        args: Vec<CedarValueJson>,
+    },
+}
+
+impl FnAndArgs {
+    pub(crate) fn fn_str(&self) -> &str {
+        match self {
+            Self::Multi { ext_fn, .. } | Self::Single { ext_fn, .. } => ext_fn,
+        }
+    }
+
+    pub(crate) fn args(&self) -> &[CedarValueJson] {
+        match self {
+            Self::Multi { args, .. } => args,
+            Self::Single { arg, .. } => std::slice::from_ref(arg),
+        }
+    }
 }
 
 impl CedarValueJson {
@@ -376,22 +417,30 @@ impl CedarValueJson {
     pub fn from_expr(expr: BorrowedRestrictedExpr<'_>) -> Result<Self, JsonSerializationError> {
         match expr.as_ref().expr_kind() {
             ExprKind::Lit(lit) => Ok(Self::from_lit(lit.clone())),
-            ExprKind::ExtensionFunctionApp { fn_name, args } => match args.len() {
-                0 => Err(JsonSerializationError::call_0_args(fn_name.clone())),
-                // PANIC SAFETY. We've checked that `args` is of length 1, fine to index at 0
-                #[allow(clippy::indexing_slicing)]
-                1 => Ok(Self::ExtnEscape {
-                    __extn: FnAndArg {
+            ExprKind::ExtensionFunctionApp { fn_name, args } => match args.as_slice() {
+                [] => Err(JsonSerializationError::call_0_args(fn_name.clone())),
+                [arg] => Ok(Self::ExtnEscape {
+                    __extn: FnAndArgs::Single {
                         ext_fn: fn_name.to_smolstr(),
                         arg: Box::new(CedarValueJson::from_expr(
                             // assuming the invariant holds for `expr`, it must also hold here
-                            BorrowedRestrictedExpr::new_unchecked(
-                                &args[0], // checked above that |args| == 1
-                            ),
+                            BorrowedRestrictedExpr::new_unchecked(arg),
                         )?),
                     },
                 }),
-                _ => Err(JsonSerializationError::call_2_or_more_args(fn_name.clone())),
+                args => Ok(Self::ExtnEscape {
+                    __extn: FnAndArgs::Multi {
+                        ext_fn: fn_name.to_smolstr(),
+                        args: args
+                            .into_iter()
+                            .map(|arg| {
+                                CedarValueJson::from_expr(BorrowedRestrictedExpr::new_unchecked(
+                                    arg,
+                                ))
+                            })
+                            .collect::<Result<Vec<_>, _>>()?,
+                    },
+                }),
             },
             ExprKind::Set(exprs) => Ok(Self::Set(
                 exprs
@@ -465,22 +514,24 @@ impl CedarValueJson {
             }
             ValueKind::ExtensionValue(ev) => {
                 let ext_func = &ev.func;
-                Ok(Self::ExtnEscape {
-                    __extn: FnAndArg {
-                        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_func.clone()))
-                            }
-                            _ => {
-                                return Err(JsonSerializationError::call_2_or_more_args(
-                                    ext_func.clone(),
-                                ))
-                            }
+                match ev.args.as_slice() {
+                    [] => Err(JsonSerializationError::call_0_args(ext_func.clone())),
+                    [ref expr] => Ok(Self::ExtnEscape {
+                        __extn: FnAndArgs::Single {
+                            ext_fn: ext_func.to_smolstr(),
+                            arg: Box::new(Self::from_expr(expr.as_borrowed())?),
                         },
-                    },
-                })
+                    }),
+                    exprs => Ok(Self::ExtnEscape {
+                        __extn: FnAndArgs::Multi {
+                            ext_fn: ext_func.to_smolstr(),
+                            args: exprs
+                                .into_iter()
+                                .map(|expr| Self::from_expr(expr.as_borrowed()))
+                                .collect::<Result<Vec<_>, _>>()?,
+                        },
+                    }),
+                }
             }
         }
     }
@@ -519,10 +570,23 @@ impl CedarValueJson {
                     Err(_) => Ok(CedarValueJson::EntityEscape { __entity }),
                 }
             }
-            CedarValueJson::ExtnEscape { __extn } => Ok(CedarValueJson::ExtnEscape {
-                __extn: FnAndArg {
-                    ext_fn: __extn.ext_fn,
-                    arg: Box::new((*__extn.arg).sub_entity_literals(mapping)?),
+            CedarValueJson::ExtnEscape {
+                __extn: FnAndArgs::Single { ext_fn, arg },
+            } => Ok(CedarValueJson::ExtnEscape {
+                __extn: FnAndArgs::Single {
+                    ext_fn,
+                    arg: Box::new((*arg).sub_entity_literals(mapping)?),
+                },
+            }),
+            CedarValueJson::ExtnEscape {
+                __extn: FnAndArgs::Multi { ext_fn, args },
+            } => Ok(CedarValueJson::ExtnEscape {
+                __extn: FnAndArgs::Multi {
+                    ext_fn,
+                    args: args
+                        .into_iter()
+                        .map(|arg| arg.sub_entity_literals(mapping))
+                        .collect::<Result<Vec<_>, _>>()?,
                 },
             }),
             v @ CedarValueJson::Bool(_) => Ok(v),
@@ -562,17 +626,21 @@ fn check_for_reserved_keys<'a>(
     }
 }
 
-impl FnAndArg {
+impl FnAndArgs {
     /// Convert this `FnAndArg` into a Cedar "restricted expression" (which will be a call to an extension constructor)
     pub fn into_expr(
         self,
         ctx: impl Fn() -> JsonDeserializationErrorContext + Clone,
     ) -> Result<RestrictedExpr, JsonDeserializationError> {
+        let ext_fn = self.fn_str();
+        let args = self.args();
         Ok(RestrictedExpr::call_extension_fn(
-            Name::from_normalized_str(&self.ext_fn).map_err(|errs| {
-                JsonDeserializationError::parse_escape(EscapeKind::Extension, self.ext_fn, errs)
+            Name::from_normalized_str(ext_fn).map_err(|errs| {
+                JsonDeserializationError::parse_escape(EscapeKind::Extension, ext_fn, errs)
             })?,
-            vec![CedarValueJson::into_expr(*self.arg, ctx)?],
+            args.into_iter()
+                .map(|arg| CedarValueJson::into_expr(arg.clone(), ctx.clone()))
+                .collect::<Result<Vec<_>, _>>()?,
         ))
     }
 }
@@ -606,7 +674,7 @@ impl<'e> ValueParser<'e> {
             let extjson: ExtnValueJson = serde_json::from_value(val).ok()?;
             match extjson {
                 ExtnValueJson::ExplicitExtnEscape {
-                    __extn: FnAndArg { ext_fn, arg },
+                    __extn: FnAndArgs::Single { ext_fn, arg },
                 } if ext_fn == "unknown" => {
                     let arg = arg.into_expr(ctx.clone()).ok()?;
                     let name = arg.as_string()?;
@@ -634,7 +702,81 @@ impl<'e> ValueParser<'e> {
             // convert that into an extension-function-call `RestrictedExpr`
             Some(SchemaType::Extension { ref name, .. }) => {
                 let extjson: ExtnValueJson = serde_json::from_value(val)?;
-                self.extn_value_json_into_rexpr(extjson, name.clone(), ctx)
+                match extjson {
+                    ExtnValueJson::ExplicitExprEscape { .. } => {
+                        Err(JsonDeserializationError::ExprTag(Box::new(ctx())))
+                    }
+                    ExtnValueJson::ExplicitExtnEscape { __extn }
+                    | ExtnValueJson::ImplicitExtnEscape(__extn) => {
+                        let func = self.extensions.func(
+                            &Name::from_normalized_str(__extn.fn_str()).map_err(|errs| {
+                                JsonDeserializationError::parse_escape(
+                                    EscapeKind::Extension,
+                                    __extn.fn_str(),
+                                    errs,
+                                )
+                            })?,
+                        )?;
+                        let arg_types = func.arg_types();
+                        let args = __extn.args();
+                        if args.len() != arg_types.len() {
+                            return Err(JsonDeserializationError::incorrect_num_of_arguments(
+                                arg_types.len(),
+                                args.len(),
+                                __extn.fn_str(),
+                            ));
+                        }
+                        Ok(RestrictedExpr::call_extension_fn(
+                            func.name().clone(),
+                            arg_types
+                                .into_iter()
+                                .zip(args.iter())
+                                .map(|(arg_type, arg)| {
+                                    // We need to recur here because there
+                                    // could be arguments of non-primitive
+                                    // types like `datetime`, which could be
+                                    // expressed using `ImplicitExtnEscape` or
+                                    // `ImplicitConstructor`
+                                    self.val_into_restricted_expr(
+                                        // So, we have to serialize
+                                        // `CedarValueJson` here
+                                        serde_json::to_value(arg)?,
+                                        Some(arg_type),
+                                        ctx.clone(),
+                                    )
+                                })
+                                .collect::<Result<Vec<_>, _>>()?,
+                        ))
+                    }
+                    ExtnValueJson::ImplicitConstructor(val) => {
+                        let expected_return_type = SchemaType::Extension { name: name.clone() };
+                        // Unfortunately, we can only allow one argument
+                        // constructor here because it's impossible to
+                        // distinguish two cases where there are
+                        // multiple arguments and where there is one
+                        // argument of a set type
+                        if let Some(constructor) = self
+                            .extensions
+                            .lookup_single_arg_constructor(&expected_return_type)
+                        {
+                            // PANIC SAFETY: we've concluded above that it has one arugment
+                            #[allow(clippy::indexing_slicing)]
+                            Ok(RestrictedExpr::call_extension_fn(
+                                constructor.name().clone(),
+                                std::iter::once(self.val_into_restricted_expr(
+                                    serde_json::to_value(val)?,
+                                    Some(&constructor.arg_types()[0]),
+                                    ctx,
+                                )?),
+                            ))
+                        } else {
+                            Err(JsonDeserializationError::missing_implied_constructor(
+                                ctx(),
+                                expected_return_type,
+                            ))
+                        }
+                    }
+                }
             }
             // The expected type is a set type. No special parsing rules apply, but
             // we need to parse the elements according to the expected element type
@@ -756,55 +898,6 @@ impl<'e> ValueParser<'e> {
             }
         }
     }
-
-    /// internal function that converts an `ExtnValueJson` into a
-    /// `RestrictedExpr`, which will be an extension constructor call.
-    ///
-    /// `expected_typename`: Specific extension type that is expected.
-    fn extn_value_json_into_rexpr(
-        &self,
-        extnjson: ExtnValueJson,
-        expected_typename: Name,
-        ctx: impl Fn() -> JsonDeserializationErrorContext + Clone,
-    ) -> Result<RestrictedExpr, JsonDeserializationError> {
-        match extnjson {
-            ExtnValueJson::ExplicitExprEscape { __expr } => {
-                Err(JsonDeserializationError::ExprTag(Box::new(ctx())))
-            }
-            ExtnValueJson::ExplicitExtnEscape { __extn }
-            | ExtnValueJson::ImplicitExtnEscape(__extn) => {
-                // reuse the same logic that parses CedarValueJson
-                let jvalue = CedarValueJson::ExtnEscape { __extn };
-                let expr = jvalue.into_expr(ctx.clone())?;
-                match expr.expr_kind() {
-                    ExprKind::ExtensionFunctionApp { .. } => Ok(expr),
-                    _ => Err(JsonDeserializationError::expected_extn_value(
-                        ctx(),
-                        Either::Right(expr.clone().into()),
-                    )),
-                }
-            }
-            ExtnValueJson::ImplicitConstructor(val) => {
-                let expected_return_type = SchemaType::Extension {
-                    name: expected_typename,
-                };
-                let func = self
-                    .extensions
-                    .lookup_single_arg_constructor(&expected_return_type)
-                    .ok_or_else(|| {
-                        JsonDeserializationError::missing_implied_constructor(
-                            ctx(),
-                            expected_return_type,
-                        )
-                    })?;
-                let arg = val.into_expr(ctx.clone())?;
-                Ok(RestrictedExpr::call_extension_fn(
-                    func.name().clone(),
-                    vec![arg],
-                ))
-            }
-        }
-    }
 }
 
 /// A (optional) static context for deserialization of entity uids
@@ -952,11 +1045,11 @@ pub enum ExtnValueJson {
     /// Explicit `__extn` escape; see notes on `CedarValueJson::ExtnEscape`
     ExplicitExtnEscape {
         /// JSON object containing the extension-constructor call
-        __extn: FnAndArg,
+        __extn: FnAndArgs,
     },
     /// Implicit `__extn` escape, in which case we'll just see the `FnAndArg`
     /// directly
-    ImplicitExtnEscape(FnAndArg),
+    ImplicitExtnEscape(FnAndArgs),
     /// Implicit `__extn` escape and constructor. Constructor is implicitly
     /// selected based on the argument type and the expected type.
     //
diff --git a/cedar-policy-core/src/est/expr.rs b/cedar-policy-core/src/est/expr.rs
index 2c58dee7..78978e6f 100644
--- a/cedar-policy-core/src/est/expr.rs
+++ b/cedar-policy-core/src/est/expr.rs
@@ -22,7 +22,7 @@ use crate::ast::Infallible;
 use crate::ast::{self, BoundedDisplay, EntityUID};
 use crate::entities::json::{
     err::EscapeKind, err::JsonDeserializationError, err::JsonDeserializationErrorContext,
-    CedarValueJson, FnAndArg,
+    CedarValueJson, FnAndArgs,
 };
 use crate::expr_builder::ExprBuilder;
 use crate::extensions::Extensions;
@@ -1125,7 +1125,7 @@ fn display_cedarvaluejson(
         }
         CedarValueJson::ExprEscape { __expr } => write!(f, "({__expr})"),
         CedarValueJson::ExtnEscape {
-            __extn: FnAndArg { ext_fn, arg },
+            __extn: FnAndArgs::Single { ext_fn, arg },
         } => {
             // search for the name and callstyle
             let style = Extensions::all_available().all_funcs().find_map(|f| {
@@ -1149,6 +1149,55 @@ fn display_cedarvaluejson(
                 }
             }
         }
+        CedarValueJson::ExtnEscape {
+            __extn: FnAndArgs::Multi { ext_fn, args },
+        } => {
+            // search for the name and callstyle
+            let style = Extensions::all_available().all_funcs().find_map(|f| {
+                if &f.name().to_smolstr() == ext_fn {
+                    Some(f.style())
+                } else {
+                    None
+                }
+            });
+            match style {
+                Some(ast::CallStyle::MethodStyle) => {
+                    // PANIC SAFETY: method-style calls must have more than one argument
+                    #[allow(clippy::indexing_slicing)]
+                    display_cedarvaluejson(f, &args[0], n)?;
+                    write!(f, ".{ext_fn}(")?;
+                    // PANIC SAFETY: method-style calls must have more than one argument
+                    #[allow(clippy::indexing_slicing)]
+                    match &args[1..] {
+                        [] => {}
+                        [args @ .., last] => {
+                            for arg in args {
+                                display_cedarvaluejson(f, arg, n)?;
+                                write!(f, ", ")?;
+                            }
+                            display_cedarvaluejson(f, last, n)?;
+                        }
+                    }
+                    write!(f, ")")?;
+                    Ok(())
+                }
+                Some(ast::CallStyle::FunctionStyle) | None => {
+                    write!(f, "{ext_fn}(")?;
+                    match &args[..] {
+                        [] => {}
+                        [args @ .., last] => {
+                            for arg in args {
+                                display_cedarvaluejson(f, arg, n)?;
+                                write!(f, ", ")?;
+                            }
+                            display_cedarvaluejson(f, last, n)?;
+                        }
+                    }
+                    write!(f, ")")?;
+                    Ok(())
+                }
+            }
+        }
         CedarValueJson::Set(v) => {
             match n {
                 Some(n) if v.len() > n => {
diff --git a/cedar-policy-core/src/extensions.rs b/cedar-policy-core/src/extensions.rs
index 72b13e19..bdb1a694 100644
--- a/cedar-policy-core/src/extensions.rs
+++ b/cedar-policy-core/src/extensions.rs
@@ -30,14 +30,13 @@ use std::collections::HashMap;
 
 use crate::ast::{Extension, ExtensionFunction, Name};
 use crate::entities::SchemaType;
+use crate::extensions::extension_initialization_errors::MultipleConstructorsSameSignatureError;
 use crate::parser::{AsLocRef, IntoMaybeLoc, Loc, MaybeLoc};
 use miette::Diagnostic;
 use thiserror::Error;
 
 use self::extension_function_lookup_errors::FuncDoesNotExistError;
-use self::extension_initialization_errors::{
-    FuncMultiplyDefinedError, MultipleConstructorsSameSignatureError,
-};
+use self::extension_initialization_errors::FuncMultiplyDefinedError;
 
 lazy_static::lazy_static! {
     static ref ALL_AVAILABLE_EXTENSION_OBJECTS: Vec<Extension> = vec![
@@ -124,7 +123,7 @@ impl<'a> Extensions<'a> {
             extensions
                 .iter()
                 .flat_map(|e| e.funcs())
-                .filter(|f| f.is_constructor() && f.arg_types().len() == 1)
+                .filter(|f| f.is_single_arg_constructor())
                 .filter_map(|f| f.return_type().map(|return_type| (return_type, f))),
         )
         .map_err(|return_type| MultipleConstructorsSameSignatureError {
@@ -174,10 +173,8 @@ impl<'a> Extensions<'a> {
         self.extensions.iter().flat_map(|ext| ext.funcs())
     }
 
-    /// Lookup a single-argument constructor by its return type and argument type.
-    ///
-    /// `None` means no constructor has that signature.
-    pub(crate) fn lookup_single_arg_constructor(
+    /// Lookup a single-argument constructor by its return type
+    pub(crate) fn lookup_single_arg_constructor<'b>(
         &self,
         return_type: &SchemaType,
     ) -> Option<&ExtensionFunction> {
diff --git a/cedar-policy-core/src/extensions/decimal.rs b/cedar-policy-core/src/extensions/decimal.rs
index 926f14b2..f1ecf760 100644
--- a/cedar-policy-core/src/extensions/decimal.rs
+++ b/cedar-policy-core/src/extensions/decimal.rs
@@ -360,33 +360,33 @@ mod tests {
                 &Name::parse_unqualified_name("decimal").expect("should be a valid identifier")
             )
             .expect("function should exist")
-            .is_constructor());
+            .is_single_arg_constructor());
         assert!(!ext
             .get_func(
                 &Name::parse_unqualified_name("lessThan").expect("should be a valid identifier")
             )
             .expect("function should exist")
-            .is_constructor());
+            .is_single_arg_constructor());
         assert!(!ext
             .get_func(
                 &Name::parse_unqualified_name("lessThanOrEqual")
                     .expect("should be a valid identifier")
             )
             .expect("function should exist")
-            .is_constructor());
+            .is_single_arg_constructor());
         assert!(!ext
             .get_func(
                 &Name::parse_unqualified_name("greaterThan").expect("should be a valid identifier")
             )
             .expect("function should exist")
-            .is_constructor());
+            .is_single_arg_constructor());
         assert!(!ext
             .get_func(
                 &Name::parse_unqualified_name("greaterThanOrEqual")
                     .expect("should be a valid identifier")
             )
             .expect("function should exist")
-            .is_constructor(),);
+            .is_single_arg_constructor(),);
     }
 
     #[test]
diff --git a/cedar-policy-core/src/extensions/ipaddr.rs b/cedar-policy-core/src/extensions/ipaddr.rs
index 758c4f41..248735e8 100644
--- a/cedar-policy-core/src/extensions/ipaddr.rs
+++ b/cedar-policy-core/src/extensions/ipaddr.rs
@@ -485,37 +485,37 @@ mod tests {
         assert!(ext
             .get_func(&Name::parse_unqualified_name("ip").expect("should be a valid identifier"))
             .expect("function should exist")
-            .is_constructor());
+            .is_single_arg_constructor());
         assert!(!ext
             .get_func(
                 &Name::parse_unqualified_name("isIpv4").expect("should be a valid identifier")
             )
             .expect("function should exist")
-            .is_constructor());
+            .is_single_arg_constructor());
         assert!(!ext
             .get_func(
                 &Name::parse_unqualified_name("isIpv6").expect("should be a valid identifier")
             )
             .expect("function should exist")
-            .is_constructor());
+            .is_single_arg_constructor());
         assert!(!ext
             .get_func(
                 &Name::parse_unqualified_name("isLoopback").expect("should be a valid identifier")
             )
             .expect("function should exist")
-            .is_constructor());
+            .is_single_arg_constructor());
         assert!(!ext
             .get_func(
                 &Name::parse_unqualified_name("isMulticast").expect("should be a valid identifier")
             )
             .expect("function should exist")
-            .is_constructor());
+            .is_single_arg_constructor());
         assert!(!ext
             .get_func(
                 &Name::parse_unqualified_name("isInRange").expect("should be a valid identifier")
             )
             .expect("function should exist")
-            .is_constructor(),);
+            .is_single_arg_constructor(),);
     }
 
     #[test]
diff --git a/cedar-policy/CHANGELOG.md b/cedar-policy/CHANGELOG.md
index 7b7b0fe2..3c1b1f1a 100644
--- a/cedar-policy/CHANGELOG.md
+++ b/cedar-policy/CHANGELOG.md
@@ -19,6 +19,10 @@ Starting with version 3.2.4, changes marked with a star (*) are _language breaki
   reporting for reduced memory usage and faster parsing. The feature is only intended for use with raw parsing
   APIs, as regular parsing performance is degraded when the `raw-parsing` feature is enabled.
 
+### Changed
+
+- Allow multiple argument extension function calls in entity JSON format. (#1697)
+
 ## [4.5.0] - 2025-06-30
 Cedar Language Version: 4.4
 
diff --git a/cedar-policy/src/api.rs b/cedar-policy/src/api.rs
index a94825a6..07976f22 100644
--- a/cedar-policy/src/api.rs
+++ b/cedar-policy/src/api.rs
@@ -4972,6 +4972,7 @@ impl From<ast::Value> for EvalResult {
 }
 
 #[doc(hidden)]
+// PANIC SAFETY: see the panic safety comments below
 #[allow(clippy::fallible_impl_from)]
 impl From<EvalResult> for Expression {
     fn from(res: EvalResult) -> Self {
diff --git a/cedar-policy/src/api/err.rs b/cedar-policy/src/api/err.rs
index c881c166..152f1613 100644
--- a/cedar-policy/src/api/err.rs
+++ b/cedar-policy/src/api/err.rs
@@ -54,9 +54,9 @@ pub mod entities_errors {
 pub mod entities_json_errors {
     pub use cedar_policy_core::entities::json::err::{
         ActionParentIsNotAction, DuplicateKey, ExpectedExtnValue, ExpectedLiteralEntityRef,
-        ExtnCall0Arguments, ExtnCall2OrMoreArguments, JsonDeserializationError, JsonError,
-        JsonSerializationError, MissingImpliedConstructor, MissingRequiredRecordAttr, ParseEscape,
-        ReservedKey, Residual, TypeMismatch, UnexpectedRecordAttr, UnexpectedRestrictedExprKind,
+        ExtnCall0Arguments, JsonDeserializationError, JsonError, JsonSerializationError,
+        MissingImpliedConstructor, MissingRequiredRecordAttr, ParseEscape, ReservedKey, Residual,
+        TypeMismatch, UnexpectedRecordAttr, UnexpectedRestrictedExprKind,
     };
 }
 


Test patch:
diff --git a/cedar-policy/src/test/test.rs b/cedar-policy/src/test/test.rs
index 7858983b..260dcbf2 100644
--- a/cedar-policy/src/test/test.rs
+++ b/cedar-policy/src/test/test.rs
@@ -2311,6 +2311,7 @@ action "g" appliesTo {
 /// (Core has similar tests, but using a stubbed implementation of Schema.)
 mod schema_based_parsing_tests {
     use super::*;
+    use cedar_policy_core::entities::json::err::JsonDeserializationError;
     use cedar_policy_core::extensions::Extensions;
     use entities::conformance::err::EntitySchemaConformanceError;
     use entities::err::EntitiesError;
@@ -2554,6 +2555,210 @@ mod schema_based_parsing_tests {
         );
     }
 
+    #[test]
+    fn multiple_extension_arguments() {
+        let (schema, _) = Schema::from_cedarschema_str(
+            r#"
+        entity E {
+            d: datetime,
+        };
+        "#,
+        )
+        .unwrap();
+        // recommended way
+        assert_matches!(
+            Entity::from_json_value(
+                json!({
+                    "uid": { "type": "E", "id": "" },
+                    "attrs": {
+                        "d": {
+                            "fn": "offset",
+                            "args": [
+                                {"fn": "datetime", "arg": "2025-07-14"},
+                                {"fn": "duration", "arg": "0h"}],
+                        }
+                    },
+                    "parents": [],
+                }
+                ),
+                Some(&schema)
+            ),
+            Ok(e) => {
+                assert_matches!(e.attr("d"), Some(Ok(EvalResult::ExtensionValue(_))));
+            }
+        );
+
+        // too many argments
+        assert_matches!(
+            Entity::from_json_value(
+                json!({
+                    "uid": { "type": "E", "id": "" },
+                    "attrs": {
+                        "d": {
+                            "fn": "offset",
+                            "args": [
+                                {"fn": "datetime", "arg": "2025-07-14"},
+                                {"fn": "duration", "arg": "0h"},
+                                {"fn": "duration", "arg": "0h"}
+                            ],
+                        }
+                    },
+                    "parents": [],
+                }
+                ),
+                Some(&schema)
+            ),
+            Err(EntitiesError::Deserialization(
+                JsonDeserializationError::IncorrectNumOfArguments(_)
+            ))
+        );
+
+        // too few argments
+        assert_matches!(
+            Entity::from_json_value(
+                json!({
+                    "uid": { "type": "E", "id": "" },
+                    "attrs": {
+                        "d": {
+                            "fn": "offset",
+                            "args": [
+                                {"fn": "datetime", "arg": "2025-07-14"},
+                            ],
+                        }
+                    },
+                    "parents": [],
+                }
+                ),
+                Some(&schema)
+            ),
+            Err(EntitiesError::Deserialization(
+                JsonDeserializationError::IncorrectNumOfArguments(_)
+            ))
+        );
+
+        assert_matches!(
+            Entity::from_json_value(
+                json!({
+                    "uid": { "type": "E", "id": "" },
+                    "attrs": {
+                        "d": {
+                            "fn": "offset",
+                            "arg":
+                                {"fn": "datetime", "arg": "2025-07-14"},
+                        }
+                    },
+                    "parents": [],
+                }
+                ),
+                Some(&schema)
+            ),
+            Err(EntitiesError::Deserialization(
+                JsonDeserializationError::IncorrectNumOfArguments(_)
+            ))
+        );
+
+        // easiest way
+        assert_matches!(
+            Entity::from_json_value(
+                json!({
+                    "uid": { "type": "E", "id": "" },
+                    "attrs": {
+                        "d": {
+                            "fn": "offset",
+                            "args": [
+                                "2025-07-14",
+                               "0h"],
+                        }
+                    },
+                    "parents": [],
+                }
+                ),
+                Some(&schema)
+            ),
+            Ok(e) => {
+                assert_matches!(e.attr("d"), Some(Ok(EvalResult::ExtensionValue(_))));
+            }
+        );
+
+        // hardest way
+        assert_matches!(
+            Entity::from_json_value(
+                json!({
+                    "uid": { "type": "E", "id": "" },
+                    "attrs": {
+                        "d": {
+                            "__extn": {
+                                "fn": "offset",
+                                "args": [
+                                {"__extn": {"fn": "datetime", "arg":"2025-07-14"}},
+                               {"__extn": {"fn": "duration", "arg":"0h"}},],
+                            }
+                        }
+                    },
+                    "parents": [],
+                }
+                ),
+                Some(&schema)
+            ),
+            Ok(e) => {
+                assert_matches!(e.attr("d"), Some(Ok(EvalResult::ExtensionValue(_))));
+            }
+        );
+
+        // in-betweens
+        assert_matches!(
+            Entity::from_json_value(
+                json!({
+                    "uid": { "type": "E", "id": "" },
+                    "attrs": {
+                        "d": {
+                            "__extn": {
+                                "fn": "offset",
+                                "args": [
+                                {"fn": "datetime", "arg":"2025-07-14"},
+                               "0h"],
+                            }
+                        }
+                    },
+                    "parents": [],
+                }
+                ),
+                Some(&schema)
+            ),
+            Ok(e) => {
+                assert_matches!(e.attr("d"), Some(Ok(EvalResult::ExtensionValue(_))));
+            }
+        );
+
+        // Arbitrarily deep
+        assert_matches!(
+            Entity::from_json_value(
+                json!({
+                    "uid": { "type": "E", "id": "" },
+                    "attrs": {
+                        "d": {
+                            "__extn": {
+                                "fn": "offset",
+                                "args": [
+                                    {"fn": "offset",
+                                    "args": [
+                                        "2025-07-14",
+                                        {"fn": "toTime", "arg": "2025-01-01"}]},
+                                    "0h"],
+                            }
+                        }
+                    },
+                    "parents": [],
+                }
+                ),
+                Some(&schema)
+            ),
+            Ok(e) => {
+                assert_matches!(e.attr("d"), Some(Ok(EvalResult::ExtensionValue(_))));
+            }
+        );
+    }
+
     /// Simple test that exercises a variety of attribute types for single entities
     #[test]
     fn single_attr_types() {