Repository: cedar-policy/cedar
Pull request #1360 (2024-12-06 15:21:19)

refactor schema arg structures in CLI
## Description of changes

Purely a refactor (no change to the actual CLI interface) of the structures for schema args in the CLI, to enable more reusability. (In particular, in a future PR, we'll hopefully use these structures in the protobufs commands as well.)

Technically a breaking change to public structures exported by the CLI library, although not a breaking change for users of the CLI as a binary.  Not sure if we care about that.

## Issue #, if available

## Checklist for requesting a review

The change in this PR is (choose one, and delete the other options):

- [ ] A breaking change requiring a major version bump to `cedar-policy` (e.g., changes to the signature of an existing API).
- [ ] A backwards-compatible change requiring a minor version bump to `cedar-policy` (e.g., addition of a new API).
- [ ] A bug fix or other functionality change requiring a patch to `cedar-policy`.
- [ ] A change "invisible" to users (e.g., documentation, changes to "internal" crates like `cedar-policy-core`, `cedar-validator`, etc.)
- [ ] A change (breaking or otherwise) that only impacts unreleased or experimental code.

I confirm that this PR (choose one, and delete the other options):

- [x] Does not update the CHANGELOG because my change does not significantly impact released code.

I confirm that [`cedar-spec`](https://github.com/cedar-policy/cedar-spec) (choose one, and delete the other options):

- [x] Does not require updates because my change does not impact the Cedar formal model or DRT infrastructure.

I confirm that [`docs.cedarpolicy.com`](https://docs.cedarpolicy.com/) (choose one, and delete the other options):

- [x] Does not require updates because my change does not impact the Cedar language specification.



Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 689afe43..7f7ee312 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -296,11 +296,11 @@ dependencies = [
 
 [[package]]
 name = "cedar-policy"
-version = "4.1.0"
+version = "4.3.0"
 dependencies = [
- "cedar-policy-core 4.1.0",
+ "cedar-policy-core",
  "cedar-policy-formatter",
- "cedar-policy-validator 4.1.0",
+ "cedar-policy-validator",
  "cool_asserts",
  "criterion",
  "dhat",
@@ -327,13 +327,13 @@ dependencies = [
 
 [[package]]
 name = "cedar-policy-cli"
-version = "4.1.0"
+version = "4.3.0"
 dependencies = [
  "assert_cmd",
  "cedar-policy",
- "cedar-policy-core 4.1.0 (git+https://github.com/cedar-policy/cedar.git?branch=main)",
+ "cedar-policy-core",
  "cedar-policy-formatter",
- "cedar-policy-validator 4.1.0 (git+https://github.com/cedar-policy/cedar.git?branch=main)",
+ "cedar-policy-validator",
  "clap",
  "glob",
  "miette",
@@ -350,7 +350,7 @@ dependencies = [
 
 [[package]]
 name = "cedar-policy-core"
-version = "4.1.0"
+version = "4.3.0"
 dependencies = [
  "arbitrary",
  "chrono",
@@ -379,37 +379,11 @@ dependencies = [
  "wasm-bindgen",
 ]
 
-[[package]]
-name = "cedar-policy-core"
-version = "4.1.0"
-source = "git+https://github.com/cedar-policy/cedar.git?branch=main#2e60d27a9536ad41824233c06e6fc7a701a404c4"
-dependencies = [
- "chrono",
- "either",
- "itertools 0.13.0",
- "lalrpop",
- "lalrpop-util",
- "lazy_static",
- "miette",
- "nonempty",
- "prost",
- "prost-build",
- "ref-cast",
- "regex",
- "rustc_lexer",
- "serde",
- "serde_json",
- "serde_with",
- "smol_str",
- "stacker",
- "thiserror 2.0.3",
-]
-
 [[package]]
 name = "cedar-policy-formatter"
-version = "4.1.0"
+version = "4.3.0"
 dependencies = [
- "cedar-policy-core 4.1.0",
+ "cedar-policy-core",
  "insta",
  "itertools 0.13.0",
  "lazy_static",
@@ -422,10 +396,10 @@ dependencies = [
 
 [[package]]
 name = "cedar-policy-validator"
-version = "4.1.0"
+version = "4.3.0"
 dependencies = [
  "arbitrary",
- "cedar-policy-core 4.1.0",
+ "cedar-policy-core",
  "cool_asserts",
  "itertools 0.13.0",
  "lalrpop",
@@ -449,38 +423,14 @@ dependencies = [
  "wasm-bindgen",
 ]
 
-[[package]]
-name = "cedar-policy-validator"
-version = "4.1.0"
-source = "git+https://github.com/cedar-policy/cedar.git?branch=main#2e60d27a9536ad41824233c06e6fc7a701a404c4"
-dependencies = [
- "cedar-policy-core 4.1.0 (git+https://github.com/cedar-policy/cedar.git?branch=main)",
- "itertools 0.13.0",
- "lalrpop",
- "lalrpop-util",
- "lazy_static",
- "miette",
- "nonempty",
- "prost",
- "prost-build",
- "ref-cast",
- "serde",
- "serde_json",
- "serde_with",
- "smol_str",
- "stacker",
- "thiserror 2.0.3",
- "unicode-security",
-]
-
 [[package]]
 name = "cedar-testing"
-version = "4.1.0"
+version = "4.3.0"
 dependencies = [
  "assert_cmd",
  "cedar-policy",
- "cedar-policy-core 4.1.0",
- "cedar-policy-validator 4.1.0",
+ "cedar-policy-core",
+ "cedar-policy-validator",
  "miette",
  "serde",
  "serde_json",
@@ -490,13 +440,13 @@ dependencies = [
 
 [[package]]
 name = "cedar-wasm"
-version = "4.1.0"
+version = "4.3.0"
 dependencies = [
  "cargo-lock",
  "cedar-policy",
- "cedar-policy-core 4.1.0",
+ "cedar-policy-core",
  "cedar-policy-formatter",
- "cedar-policy-validator 4.1.0",
+ "cedar-policy-validator",
  "console_error_panic_hook",
  "cool_asserts",
  "itertools 0.13.0",
diff --git a/Cargo.toml b/Cargo.toml
index 5b42ea90..20d2569e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,7 +23,7 @@ debug = "line-tables-only"  # this adds more debug symbols/info to the binary th
 [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)
 rust-version = "1.77"
-version = "4.1.0"
+version = "4.3.0"
 homepage = "https://cedarpolicy.com"
 keywords = ["cedar", "authorization", "policy", "security"]
 categories = ["compilers", "config"]
diff --git a/cedar-policy-cli/Cargo.toml b/cedar-policy-cli/Cargo.toml
index e245ff53..e64dd98a 100644
--- a/cedar-policy-cli/Cargo.toml
+++ b/cedar-policy-cli/Cargo.toml
@@ -11,10 +11,10 @@ homepage.workspace = true
 repository.workspace = true
 
 [dependencies]
-cedar-policy = { version = "=4.1.0", path = "../cedar-policy" }
-cedar-policy-formatter = { version = "=4.1.0", path = "../cedar-policy-formatter" }
-cedar-policy-core = { package= "cedar-policy-core", git = "https://github.com/cedar-policy/cedar.git", branch = "main", version = "=4.1.0"}
-cedar-policy-validator = { package= "cedar-policy-validator", git = "https://github.com/cedar-policy/cedar.git", branch = "main", version = "=4.1.0"}
+cedar-policy = { version = "=4.3.0", path = "../cedar-policy" }
+cedar-policy-formatter = { version = "=4.3.0", path = "../cedar-policy-formatter" }
+cedar-policy-core = { version = "=4.3.0", path = "../cedar-policy-core" }
+cedar-policy-validator = { version = "=4.3.0", path = "../cedar-policy-validator" }
 clap = { version = "4", features = ["derive", "env"] }
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
@@ -28,7 +28,7 @@ prost-build = {version = "0.13", optional = true}
 
 [features]
 default = []
-experimental = ["permissive-validate", "partial-validate", "partial-eval"]
+experimental = ["permissive-validate", "partial-validate", "partial-eval", "protobufs"]
 permissive-validate = ["cedar-policy/permissive-validate"]
 partial-validate = ["cedar-policy/partial-validate"]
 partial-eval = ["cedar-policy/partial-eval"]
diff --git a/cedar-policy-cli/protobuf_schema/AST.proto b/cedar-policy-cli/protobuf_schema/AST.proto
index 1f387058..863111f8 100644
--- a/cedar-policy-cli/protobuf_schema/AST.proto
+++ b/cedar-policy-cli/protobuf_schema/AST.proto
@@ -236,6 +236,7 @@ message Expr {
         enum Op {
             Not = 0;
             Neg = 1;
+            IsEmpty = 2;
         }
     }
 
diff --git a/cedar-policy-cli/src/lib.rs b/cedar-policy-cli/src/lib.rs
index 5c8e2a01..5ee89912 100644
--- a/cedar-policy-cli/src/lib.rs
+++ b/cedar-policy-cli/src/lib.rs
@@ -27,7 +27,7 @@ use std::{
     collections::HashMap,
     fmt::{self, Display},
     fs::OpenOptions,
-    path::Path,
+    path::{Path, PathBuf},
     process::{ExitCode, Termination},
     str::FromStr,
     time::Instant,
@@ -171,20 +171,15 @@ pub enum SchemaTranslationDirection {
     CedarToJson,
 }
 
-#[derive(Debug, Clone, Copy, ValueEnum)]
+#[derive(Debug, Default, Clone, Copy, ValueEnum)]
 pub enum SchemaFormat {
     /// the Cedar format
+    #[default]
     Cedar,
     /// JSON format
     Json,
 }
 
-impl Default for SchemaFormat {
-    fn default() -> Self {
-        Self::Cedar
-    }
-}
-
 #[derive(Debug, Clone, Copy, ValueEnum)]
 pub enum ValidationMode {
     /// Strict validation
@@ -197,18 +192,15 @@ pub enum ValidationMode {
 
 #[derive(Args, Debug)]
 pub struct ValidateArgs {
-    /// File containing the schema
-    #[arg(short, long = "schema", value_name = "FILE")]
-    pub schema_file: String,
+    /// Schema args (incorporated by reference)
+    #[command(flatten)]
+    pub schema: SchemaArgs,
     /// Policies args (incorporated by reference)
     #[command(flatten)]
     pub policies: PoliciesArgs,
     /// Report a validation failure for non-fatal warnings
     #[arg(long)]
     pub deny_warnings: bool,
-    /// Schema format (Cedar or JSON)
-    #[arg(long, value_enum, default_value_t = SchemaFormat::Cedar)]
-    pub schema_format: SchemaFormat,
     /// Validate the policy using this mode.
     /// The options `permissive` and `partial` are experimental
     /// and will cause the CLI to exit if it was not built with the
@@ -490,6 +482,64 @@ impl PoliciesArgs {
     }
 }
 
+/// This struct contains the arguments that together specify an input schema.
+#[derive(Args, Debug)]
+pub struct SchemaArgs {
+    /// File containing the schema
+    #[arg(short, long = "schema", value_name = "FILE")]
+    pub schema_file: PathBuf,
+    /// Schema format
+    #[arg(long, value_enum, default_value_t)]
+    pub schema_format: SchemaFormat,
+}
+
+impl SchemaArgs {
+    /// Turn this `SchemaArgs` into the appropriate `Schema` object
+    fn get_schema(&self) -> Result<Schema> {
+        read_schema_from_file(&self.schema_file, self.schema_format)
+    }
+}
+
+/// This struct contains the arguments that together specify an input schema,
+/// for commands where the schema is optional.
+#[derive(Args, Debug)]
+pub struct OptionalSchemaArgs {
+    /// File containing the schema
+    #[arg(short, long = "schema", value_name = "FILE")]
+    pub schema_file: Option<PathBuf>,
+    /// Schema format
+    #[arg(long, value_enum, default_value_t)]
+    pub schema_format: SchemaFormat,
+}
+
+impl OptionalSchemaArgs {
+    /// Turn this `OptionalSchemaArgs` into the appropriate `Schema` object, or `None`
+    fn get_schema(&self) -> Result<Option<Schema>> {
+        let Some(schema_file) = &self.schema_file else {
+            return Ok(None);
+        };
+        read_schema_from_file(schema_file, self.schema_format).map(Some)
+    }
+}
+
+fn read_schema_from_file(path: impl AsRef<Path>, format: SchemaFormat) -> Result<Schema> {
+    let path = path.as_ref();
+    let schema_src = read_from_file(path, "schema")?;
+    match format {
+        SchemaFormat::Json => Schema::from_json_str(&schema_src)
+            .wrap_err_with(|| format!("failed to parse schema from file {}", path.display())),
+        SchemaFormat::Cedar => {
+            let (schema, warnings) = Schema::from_cedarschema_str(&schema_src)
+                .wrap_err_with(|| format!("failed to parse schema from file {}", path.display()))?;
+            for warning in warnings {
+                let report = miette::Report::new(warning);
+                eprintln!("{:?}", report);
+            }
+            Ok(schema)
+        }
+    }
+}
+
 #[derive(Args, Debug)]
 pub struct AuthorizeArgs {
     /// Request args (incorporated by reference)
@@ -498,15 +548,12 @@ pub struct AuthorizeArgs {
     /// Policies args (incorporated by reference)
     #[command(flatten)]
     pub policies: PoliciesArgs,
-    /// File containing schema information
+    /// Schema args (incorporated by reference)
     ///
     /// Used to populate the store with action entities and for schema-based
     /// parsing of entity hierarchy, if present
-    #[arg(short, long = "schema", value_name = "FILE")]
-    pub schema_file: Option<String>,
-    /// Schema format (Cedar or JSON)
-    #[arg(long, value_enum, default_value_t = SchemaFormat::Cedar)]
-    pub schema_format: SchemaFormat,
+    #[command(flatten)]
+    pub schema: OptionalSchemaArgs,
     /// File containing JSON representation of the Cedar entity hierarchy
     #[arg(long = "entities", value_name = "FILE")]
     pub entities_file: String,
@@ -663,14 +710,12 @@ pub struct EvaluateArgs {
     /// Request args (incorporated by reference)
     #[command(flatten)]
     pub request: RequestArgs,
-    /// File containing schema information
+    /// Schema args (incorporated by reference)
+    ///
     /// Used to populate the store with action entities and for schema-based
     /// parsing of entity hierarchy, if present
-    #[arg(short, long = "schema", value_name = "FILE")]
-    pub schema_file: Option<String>,
-    /// Schema format (Cedar or JSON)
-    #[arg(long, value_enum, default_value_t = SchemaFormat::Cedar)]
-    pub schema_format: SchemaFormat,
+    #[command(flatten)]
+    pub schema: OptionalSchemaArgs,
     /// File containing JSON representation of the Cedar entity hierarchy.
     /// This is optional; if not present, we'll just use an empty hierarchy.
     #[arg(long = "entities", value_name = "FILE")]
@@ -753,7 +798,7 @@ pub fn validate(args: &ValidateArgs) -> CedarExitCode {
         }
     };
 
-    let schema = match read_schema_file(&args.schema_file, args.schema_format) {
+    let schema = match args.schema.get_schema() {
         Ok(schema) => schema,
         Err(e) => {
             println!("{e:?}");
@@ -783,14 +828,9 @@ pub fn validate(args: &ValidateArgs) -> CedarExitCode {
 
 pub fn evaluate(args: &EvaluateArgs) -> (CedarExitCode, EvalResult) {
     println!();
-    let schema = match args
-        .schema_file
-        .as_ref()
-        .map(|f| read_schema_file(f, args.schema_format))
-    {
-        None => None,
-        Some(Ok(schema)) => Some(schema),
-        Some(Err(e)) => {
+    let schema = match args.schema.get_schema() {
+        Ok(opt) => opt,
+        Err(e) => {
             println!("{e:?}");
             return (CedarExitCode::Failure, EvalResult::Bool(false));
         }
@@ -1222,8 +1262,7 @@ pub fn authorize(args: &AuthorizeArgs) -> CedarExitCode {
         &args.request,
         &args.policies,
         &args.entities_file,
-        args.schema_file.as_ref(),
-        args.schema_format,
+        &args.schema,
         args.timing,
     );
     match ans {
@@ -1468,36 +1507,12 @@ enum JsonPolicyType {
     PolicySet,
 }
 
-fn read_schema_file(
-    filename: impl AsRef<Path> + std::marker::Copy,
-    format: SchemaFormat,
-) -> Result<Schema> {
-    let schema_src = read_from_file(filename, "schema")?;
-    match format {
-        SchemaFormat::Json => Schema::from_json_str(&schema_src).wrap_err_with(|| {
-            format!(
-                "failed to parse schema from file {}",
-                filename.as_ref().display()
-            )
-        }),
-        SchemaFormat::Cedar => {
-            let (schema, warnings) = Schema::from_cedarschema_str(&schema_src)?;
-            for warning in warnings {
-                let report = miette::Report::new(warning);
-                eprintln!("{:?}", report);
-            }
-            Ok(schema)
-        }
-    }
-}
-
 /// This uses the Cedar API to call the authorization engine.
 fn execute_request(
     request: &RequestArgs,
     policies: &PoliciesArgs,
     entities_filename: impl AsRef<Path>,
-    schema_filename: Option<impl AsRef<Path> + std::marker::Copy>,
-    schema_format: SchemaFormat,
+    schema: &OptionalSchemaArgs,
     compute_duration: bool,
 ) -> Result<Response, Vec<Report>> {
     let mut errs = vec![];
@@ -1508,10 +1523,9 @@ fn execute_request(
             PolicySet::new()
         }
     };
-    let schema = match schema_filename.map(|f| read_schema_file(f, schema_format)) {
-        None => None,
-        Some(Ok(schema)) => Some(schema),
-        Some(Err(e)) => {
+    let schema = match schema.get_schema() {
+        Ok(opt) => opt,
+        Err(e) => {
             errs.push(e);
             None
         }
diff --git a/cedar-policy-core/protobuf_schema/AST.proto b/cedar-policy-core/protobuf_schema/AST.proto
index 1f387058..863111f8 100644
--- a/cedar-policy-core/protobuf_schema/AST.proto
+++ b/cedar-policy-core/protobuf_schema/AST.proto
@@ -236,6 +236,7 @@ message Expr {
         enum Op {
             Not = 0;
             Neg = 1;
+            IsEmpty = 2;
         }
     }
 
diff --git a/cedar-policy-core/src/ast/expr.rs b/cedar-policy-core/src/ast/expr.rs
index 7161c346..03dd19aa 100644
--- a/cedar-policy-core/src/ast/expr.rs
+++ b/cedar-policy-core/src/ast/expr.rs
@@ -185,30 +185,6 @@ impl From<PartialValue> for Expr {
     }
 }
 
-impl<T> ExprKind<T> {
-    /// Describe this operator for error messages.
-    pub fn operator_description(self: &ExprKind<T>) -> String {
-        match self {
-            ExprKind::Lit(_) => "literal".to_string(),
-            ExprKind::Var(_) => "variable".to_string(),
-            ExprKind::Slot(_) => "slot".to_string(),
-            ExprKind::Unknown(_) => "unknown".to_string(),
-            ExprKind::If { .. } => "if".to_string(),
-            ExprKind::And { .. } => "&&".to_string(),
-            ExprKind::Or { .. } => "||".to_string(),
-            ExprKind::UnaryApp { op, .. } => op.to_string(),
-            ExprKind::BinaryApp { op, .. } => op.to_string(),
-            ExprKind::ExtensionFunctionApp { fn_name, .. } => fn_name.to_string(),
-            ExprKind::GetAttr { .. } => "get attribute".to_string(),
-            ExprKind::HasAttr { .. } => "has attribute".to_string(),
-            ExprKind::Like { .. } => "like".to_string(),
-            ExprKind::Is { .. } => "is".to_string(),
-            ExprKind::Set(_) => "set".to_string(),
-            ExprKind::Record(_) => "record".to_string(),
-        }
-    }
-}
-
 impl<T> Expr<T> {
     fn new(expr_kind: ExprKind<T>, source_loc: Option<Loc>, data: T) -> Self {
         Self {
@@ -355,6 +331,10 @@ impl<T> Expr<T> {
             ExprKind::UnaryApp {
                 op: UnaryOp::Not, ..
             } => Some(Type::Bool),
+            ExprKind::UnaryApp {
+                op: UnaryOp::IsEmpty,
+                ..
+            } => Some(Type::Bool),
             ExprKind::BinaryApp {
                 op: BinaryOp::Add | BinaryOp::Mul | BinaryOp::Sub,
                 ..
@@ -525,6 +505,11 @@ impl Expr {
         ExprBuilder::new().contains_any(e1, e2)
     }
 
+    /// Create a `isEmpty` expression. Argument must evaluate to Set type
+    pub fn is_empty(e: Expr) -> Self {
+        ExprBuilder::new().is_empty(e)
+    }
+
     /// Create a `getTag` expression.
     /// `expr` must evaluate to Entity type, `tag` must evaluate to String type.
     pub fn get_tag(expr: Expr, tag: Expr) -> Self {
@@ -1396,6 +1381,14 @@ impl<T> ExprBuilder<T> {
         })
     }
 
+    /// Create an 'is_empty' expression. Argument must evaluate to Set type
+    pub fn is_empty(self, expr: Expr<T>) -> Expr<T> {
+        self.with_expr_kind(ExprKind::UnaryApp {
+            op: UnaryOp::IsEmpty,
+            arg: Arc::new(expr),
+        })
+    }
+
     /// Create a 'getTag' expression.
     /// `expr` must evaluate to Entity type, `tag` must evaluate to String type.
     pub fn get_tag(self, expr: Expr<T>, tag: Expr<T>) -> Expr<T> {
@@ -2246,6 +2239,10 @@ mod test {
                 ExprBuilder::with_data(1).contains_any(temp.clone(), temp.clone()),
                 Expr::contains_any(Expr::val(1), Expr::val(1)),
             ),
+            (
+                ExprBuilder::with_data(1).is_empty(temp.clone()),
+                Expr::is_empty(Expr::val(1)),
+            ),
             (
                 ExprBuilder::with_data(1).set([temp.clone()]),
                 Expr::set([Expr::val(1)]),
diff --git a/cedar-policy-core/src/ast/ops.rs b/cedar-policy-core/src/ast/ops.rs
index d84c26eb..06f59069 100644
--- a/cedar-policy-core/src/ast/ops.rs
+++ b/cedar-policy-core/src/ast/ops.rs
@@ -14,7 +14,6 @@
  * limitations under the License.
  */
 
-use crate::ast::CallStyle;
 use serde::{Deserialize, Serialize};
 
 #[cfg(feature = "protobufs")]
@@ -32,6 +31,20 @@ pub enum UnaryOp {
     ///
     /// Argument must have Long type
     Neg,
+    /// isEmpty test for sets
+    ///
+    /// Argument must have Set type
+    IsEmpty,
+}
+
+impl std::fmt::Display for UnaryOp {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            UnaryOp::Not => write!(f, "!"),
+            UnaryOp::Neg => write!(f, "-"),
+            UnaryOp::IsEmpty => write!(f, "isEmpty"),
+        }
+    }
 }
 
 #[cfg(feature = "protobufs")]
@@ -40,6 +53,7 @@ impl From<&proto::expr::unary_app::Op> for UnaryOp {
         match v {
             proto::expr::unary_app::Op::Not => UnaryOp::Not,
             proto::expr::unary_app::Op::Neg => UnaryOp::Neg,
+            proto::expr::unary_app::Op::IsEmpty => UnaryOp::IsEmpty,
         }
     }
 }
@@ -50,6 +64,7 @@ impl From<&UnaryOp> for proto::expr::unary_app::Op {
         match v {
             UnaryOp::Not => proto::expr::unary_app::Op::Not,
             UnaryOp::Neg => proto::expr::unary_app::Op::Neg,
+            UnaryOp::IsEmpty => proto::expr::unary_app::Op::IsEmpty,
         }
     }
 }
@@ -124,25 +139,16 @@ pub enum BinaryOp {
     HasTag,
 }
 
-impl std::fmt::Display for UnaryOp {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            UnaryOp::Not => write!(f, "!_"),
-            UnaryOp::Neg => write!(f, "-_"),
-        }
-    }
-}
-
 impl std::fmt::Display for BinaryOp {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
-            BinaryOp::Eq => write!(f, "_==_"),
-            BinaryOp::Less => write!(f, "_<_"),
-            BinaryOp::LessEq => write!(f, "_<=_"),
-            BinaryOp::Add => write!(f, "_+_"),
-            BinaryOp::Sub => write!(f, "_-_"),
-            BinaryOp::Mul => write!(f, "_*_"),
-            BinaryOp::In => write!(f, "_in_"),
+            BinaryOp::Eq => write!(f, "=="),
+            BinaryOp::Less => write!(f, "<"),
+            BinaryOp::LessEq => write!(f, "<="),
+            BinaryOp::Add => write!(f, "+"),
+            BinaryOp::Sub => write!(f, "-"),
+            BinaryOp::Mul => write!(f, "*"),
+            BinaryOp::In => write!(f, "in"),
             BinaryOp::Contains => write!(f, "contains"),
             BinaryOp::ContainsAll => write!(f, "containsAll"),
             BinaryOp::ContainsAny => write!(f, "containsAny"),
@@ -191,12 +197,3 @@ impl From<&BinaryOp> for proto::expr::binary_app::Op {
         }
     }
 }
-
-impl std::fmt::Display for CallStyle {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::FunctionStyle => write!(f, "function-style"),
-            Self::MethodStyle => write!(f, "method-style"),
-        }
-    }
-}
diff --git a/cedar-policy-core/src/est.rs b/cedar-policy-core/src/est.rs
index c34fa9fa..bff81c47 100644
--- a/cedar-policy-core/src/est.rs
+++ b/cedar-policy-core/src/est.rs
@@ -2220,6 +2220,7 @@ mod test {
                 principal.owners.contains("foo")
                 && principal.owners.containsAny([1, Linux::Group::"sudoers"])
                 && [2+3, "spam"].containsAll(resource.foos)
+                && context.violations.isEmpty()
             };
         "#;
         let cst = parser::text_to_cst::parse_policy(policy)
@@ -2247,66 +2248,82 @@ mod test {
                                 "left": {
                                     "&&": {
                                         "left": {
-                                            "contains": {
+                                            "&&": {
                                                 "left": {
-                                                    ".": {
+                                                    "contains": {
                                                         "left": {
-                                                            "Var": "principal"
+                                                            ".": {
+                                                                "left": {
+                                                                    "Var": "principal"
+                                                                },
+                                                                "attr": "owners"
+                                                            }
                                                         },
-                                                        "attr": "owners"
+                                                        "right": {
+                                                            "Value": "foo"
+                                                        }
                                                     }
                                                 },
                                                 "right": {
-                                                    "Value": "foo"
+                                                    "containsAny": {
+                                                        "left": {
+                                                            ".": {
+                                                                "left": {
+                                                                    "Var": "principal"
+                                                                },
+                                                                "attr": "owners"
+                                                            }
+                                                        },
+                                                        "right": {
+                                                            "Set": [
+                                                                { "Value": 1 },
+                                                                { "Value": {
+                                                                    "__entity": {
+                                                                        "type": "Linux::Group",
+                                                                        "id": "sudoers"
+                                                                    }
+                                                                } }
+                                                            ]
+                                                        }
+                                                    }
                                                 }
                                             }
                                         },
                                         "right": {
-                                            "containsAny": {
+                                            "containsAll": {
                                                 "left": {
+                                                    "Set": [
+                                                        { "+": {
+                                                            "left": {
+                                                                "Value": 2
+                                                            },
+                                                            "right": {
+                                                                "Value": 3
+                                                            }
+                                                        } },
+                                                        { "Value": "spam" },
+                                                    ]
+                                                },
+                                                "right": {
                                                     ".": {
                                                         "left": {
-                                                            "Var": "principal"
+                                                            "Var": "resource"
                                                         },
-                                                        "attr": "owners"
+                                                        "attr": "foos"
                                                     }
-                                                },
-                                                "right": {
-                                                    "Set": [
-                                                        { "Value": 1 },
-                                                        { "Value": {
-                                                            "__entity": {
-                                                                "type": "Linux::Group",
-                                                                "id": "sudoers"
-                                                            }
-                                                        } }
-                                                    ]
                                                 }
                                             }
                                         }
                                     }
                                 },
                                 "right": {
-                                    "containsAll": {
-                                        "left": {
-                                            "Set": [
-                                                { "+": {
-                                                    "left": {
-                                                        "Value": 2
-                                                    },
-                                                    "right": {
-                                                        "Value": 3
-                                                    }
-                                                } },
-                                                { "Value": "spam" },
-                                            ]
-                                        },
-                                        "right": {
+                                    "isEmpty": {
+                                        "arg": {
                                             ".": {
                                                 "left": {
-                                                    "Var": "resource"
+                                                    "Var": "context"
                                                 },
-                                                "attr": "foos"
+                                                "attr": "violations"
                                             }
                                         }
                                     }
diff --git a/cedar-policy-core/src/est/expr.rs b/cedar-policy-core/src/est/expr.rs
index fbc6b74f..e6bbe544 100644
--- a/cedar-policy-core/src/est/expr.rs
+++ b/cedar-policy-core/src/est/expr.rs
@@ -303,6 +303,12 @@ pub enum ExprNoExt {
         /// Right-hand argument (inside the `()`)
         right: Arc<Expr>,
     },
+    /// `isEmpty()`
+    #[serde(rename = "isEmpty")]
+    IsEmpty {
+        /// Argument
+        arg: Arc<Expr>,
+    },
     /// `getTag()`
     #[serde(rename = "getTag")]
     GetTag {
@@ -557,6 +563,11 @@ impl Expr {
         })
     }
 
+    /// `arg.isEmpty()`
+    pub fn is_empty(arg: Arc<Expr>) -> Self {
+        Expr::ExprNoExt(ExprNoExt::IsEmpty { arg })
+    }
+
     /// `left.getTag(right)`
     pub fn get_tag(left: Arc<Expr>, right: Expr) -> Self {
         Expr::ExprNoExt(ExprNoExt::GetTag {
@@ -731,6 +742,9 @@ impl Expr {
                         right: Arc::new((*right).clone().sub_entity_literals(mapping)?),
                     }))
                 }
+                ExprNoExt::IsEmpty { arg } => Ok(Expr::ExprNoExt(ExprNoExt::IsEmpty {
+                    arg: Arc::new((*arg).clone().sub_entity_literals(mapping)?),
+                })),
                 ExprNoExt::GetTag { left, right } => Ok(Expr::ExprNoExt(ExprNoExt::GetTag {
                     left: Arc::new((*left).clone().sub_entity_literals(mapping)?),
                     right: Arc::new((*right).clone().sub_entity_literals(mapping)?),
@@ -884,6 +898,9 @@ impl Expr {
                 (*left).clone().try_into_ast(id.clone())?,
                 (*right).clone().try_into_ast(id)?,
             )),
+            Expr::ExprNoExt(ExprNoExt::IsEmpty { arg }) => {
+                Ok(ast::Expr::is_empty((*arg).clone().try_into_ast(id)?))
+            }
             Expr::ExprNoExt(ExprNoExt::GetTag { left, right }) => Ok(ast::Expr::get_tag(
                 (*left).clone().try_into_ast(id.clone())?,
                 (*right).clone().try_into_ast(id)?,
@@ -1011,6 +1028,7 @@ impl<T: Clone> From<ast::Expr<T>> for Expr {
                 match op {
                     ast::UnaryOp::Not => Expr::not(arg),
                     ast::UnaryOp::Neg => Expr::neg(arg),
+                    ast::UnaryOp::IsEmpty => Expr::is_empty(Arc::new(arg)),
                 }
             }
             ast::ExprKind::BinaryApp { op, arg1, arg2 } => {
@@ -1492,6 +1510,10 @@ impl TryFrom<&Node<Option<cst::Member>>> for Expr {
                                     left,
                                     extract_single_argument(args, "containsAny()", &access.loc)?,
                                 )),
+                                "isEmpty" => {
+                                    require_zero_arguments(args, "isEmpty()", &access.loc)?;
+                                    Either::Right(Expr::is_empty(left))
+                                }
                                 "getTag" => Either::Right(Expr::get_tag(
                                     left,
                                     extract_single_argument(args, "getTag()", &access.loc)?,
@@ -1560,6 +1582,21 @@ pub fn extract_single_argument<T>(
     }
 }
 
+/// Return a wrong arity error if the iterator has any elements.
+pub fn require_zero_arguments<T>(
+    args: impl ExactSizeIterator<Item = T>,
+    fn_name: &'static str,
+    loc: &Loc,
+) -> Result<(), ParseErrors> {
+    match args.len() {
+        0 => Ok(()),
+        n => Err(ParseErrors::singleton(ToASTError::new(
+            ToASTErrorKind::wrong_arity(fn_name, 0, n),
+            loc.clone(),
+        ))),
+    }
+}
+
 impl TryFrom<&Node<Option<cst::Literal>>> for Expr {
     type Error = ParseErrors;
     fn try_from(lit: &Node<Option<cst::Literal>>) -> Result<Expr, ParseErrors> {
@@ -1800,6 +1837,10 @@ impl std::fmt::Display for ExprNoExt {
                 maybe_with_parens(f, left)?;
                 write!(f, ".containsAny({right})")
             }
+            ExprNoExt::IsEmpty { arg } => {
+                maybe_with_parens(f, arg)?;
+                write!(f, ".isEmpty()")
+            }
             ExprNoExt::GetTag { left, right } => {
                 maybe_with_parens(f, left)?;
                 write!(f, ".getTag({right})")
@@ -1924,6 +1965,7 @@ fn maybe_with_parens(f: &mut std::fmt::Formatter<'_>, expr: &Expr) -> std::fmt::
         Expr::ExprNoExt(ExprNoExt::Contains { .. }) |
         Expr::ExprNoExt(ExprNoExt::ContainsAll { .. }) |
         Expr::ExprNoExt(ExprNoExt::ContainsAny { .. }) |
+        Expr::ExprNoExt(ExprNoExt::IsEmpty { .. }) |
         Expr::ExprNoExt(ExprNoExt::GetAttr { .. }) |
         Expr::ExprNoExt(ExprNoExt::HasAttr { .. }) |
         Expr::ExprNoExt(ExprNoExt::GetTag { .. }) |
diff --git a/cedar-policy-core/src/evaluator.rs b/cedar-policy-core/src/evaluator.rs
index fb8edc44..6f91d810 100644
--- a/cedar-policy-core/src/evaluator.rs
+++ b/cedar-policy-core/src/evaluator.rs
@@ -367,6 +367,10 @@ impl<'e> Evaluator<'e> {
                             .into()),
                         }
                     }
+                    UnaryOp::IsEmpty => {
+                        let s = arg.get_as_set()?;
+                        Ok(s.is_empty().into())
+                    }
                 },
                 // NOTE, there was a bug here found during manual review. (I forgot to wrap in unary_app call)
                 // Could be a nice target for fault injection
@@ -1013,6 +1017,7 @@ pub(crate) mod test {
                         ])
                         .unwrap(),
                     ),
+                    ("violations".into(), RestrictedExpr::set([])),
                 ],
                 Extensions::none(),
             )
@@ -4371,7 +4376,69 @@ pub(crate) mod test {
     }
 
     #[test]
-    fn interpret_contains_all_and_contains_any() -> Result<()> {
+    fn interpret_is_empty() {
+        let request = basic_request();
+        let entities = basic_entities();
+        let eval = Evaluator::new(request, &entities, Extensions::none());
+        // [].isEmpty()
+        assert_eq!(
+            eval.interpret_inline_policy(&Expr::is_empty(Expr::set([]),)),
+            Ok(Value::from(true))
+        );
+        // [1].isEmpty()
+        assert_eq!(
+            eval.interpret_inline_policy(&Expr::is_empty(Expr::set(vec![Expr::val(1)]),)),
+            Ok(Value::from(false))
+        );
+        // [false].isEmpty()
+        assert_eq!(
+            eval.interpret_inline_policy(&Expr::is_empty(Expr::set(vec![Expr::val(false)]),)),
+            Ok(Value::from(false))
+        );
+        // [1,2,3,4,5,User::"alice"].isEmpty()
+        assert_eq!(
+            eval.interpret_inline_policy(&Expr::is_empty(Expr::set(vec![
+                Expr::val(1),
+                Expr::val(2),
+                Expr::val(3),
+                Expr::val(4),
+                Expr::val(5),
+                Expr::val(EntityUID::with_eid("jane"))
+            ]))),
+            Ok(Value::from(false))
+        );
+        // 0.isEmpty()
+        assert_matches!(
+            eval.interpret_inline_policy(&Expr::is_empty(
+                Expr::val(0)
+            )),
+            Err(e) => {
+                expect_err(
+                    "",
+                    &miette::Report::new(e),
+                    &ExpectedErrorMessageBuilder::error("type error: expected set, got long").build(),
+                );
+            }
+        );
+        // { foo: [] }.isEmpty()
+        assert_matches!(
+            eval.interpret_inline_policy(&Expr::is_empty(
+                Expr::record([
+                    ("foo".into(), Expr::set([]))
+                ]).unwrap()
+            )),
+            Err(e) => {
+                expect_err(
+                    "",
+                    &miette::Report::new(e),
+                    &ExpectedErrorMessageBuilder::error("type error: expected set, got record").build(),
+                );
+            }
+        );
+    }
+
+    #[test]
+    fn interpret_contains_all_and_contains_any() {
         let request = basic_request();
         let entities = basic_entities();
         let eval = Evaluator::new(request, &entities, Extensions::none());
@@ -4632,7 +4699,6 @@ pub(crate) mod test {
                 assert_eq!(advice, None);
             }
         );
-        Ok(())
     }
 
     #[test]
@@ -5815,6 +5881,10 @@ pub(crate) mod test {
         let e = Expr::unary_app(UnaryOp::Not, Expr::unknown(Unknown::new_untyped("a")));
         let r = eval.partial_interpret(&e, &HashMap::new()).unwrap();
         assert_eq!(r, PartialValue::Residual(e));
+
+        let e = Expr::unary_app(UnaryOp::IsEmpty, Expr::unknown(Unknown::new_untyped("a")));
+        let r = eval.partial_interpret(&e, &HashMap::new()).unwrap();
+        assert_eq!(r, PartialValue::Residual(e));
     }
 
     #[test]
diff --git a/cedar-policy-core/src/parser.rs b/cedar-policy-core/src/parser.rs
index b885b449..428adb6b 100644
--- a/cedar-policy-core/src/parser.rs
+++ b/cedar-policy-core/src/parser.rs
@@ -614,12 +614,9 @@ mod tests {
     /// Tests parser+evaluator with builtin methods `containsAll()`, `hasTag()`, `getTag()`
     #[test]
     fn interpret_methods() {
-        // The below tests check not only that we get the expected `Value`, but
-        // that it has the expected source location.
-        // See note on this in the above test.
-
         let src = r#"
             [2, 3, "foo"].containsAll([3, "foo"])
+            && context.violations.isEmpty()
             && principal.hasTag(resource.getTag(context.cur_time))
         "#;
         let request = eval::test::basic_request();
diff --git a/cedar-policy-core/src/parser/cst_to_ast.rs b/cedar-policy-core/src/parser/cst_to_ast.rs
index 42675798..bb329ea4 100644
--- a/cedar-policy-core/src/parser/cst_to_ast.rs
+++ b/cedar-policy-core/src/parser/cst_to_ast.rs
@@ -43,7 +43,7 @@ use crate::ast::{
     self, ActionConstraint, CallStyle, Integer, Pattern, PatternElem, PolicySetError,
     PrincipalConstraint, PrincipalOrResourceConstraint, ResourceConstraint, UnreservedId,
 };
-use crate::est::extract_single_argument;
+use crate::est::{extract_single_argument, require_zero_arguments};
 use crate::fuzzy_match::fuzzy_search_limited;
 use itertools::Either;
 use nonempty::nonempty;
@@ -489,6 +489,10 @@ impl ast::UnreservedId {
                 .map(|arg| construct_method_contains_all(e, arg, loc.clone())),
             "containsAny" => extract_single_argument(args.into_iter(), "containsAny", loc)
                 .map(|arg| construct_method_contains_any(e, arg, loc.clone())),
+            "isEmpty" => {
+                require_zero_arguments(args.into_iter(), "isEmpty", loc)?;
+                Ok(construct_method_is_empty(e, loc.clone()))
+            }
             "getTag" => extract_single_argument(args.into_iter(), "getTag", loc)
                 .map(|arg| construct_method_get_tag(e, arg, loc.clone())),
             "hasTag" => extract_single_argument(args.into_iter(), "hasTag", loc)
@@ -1671,7 +1675,7 @@ impl ast::Name {
             if EXTENSION_STYLES.methods.contains(&id)
                 || matches!(
                     id.as_ref(),
-                    "contains" | "containsAll" | "containsAny" | "getTag" | "hasTag"
+                    "contains" | "containsAll" | "containsAny" | "isEmpty" | "getTag" | "hasTag"
                 )
             {
                 return Err(ToASTError::new(
@@ -2011,6 +2015,9 @@ fn construct_method_contains_any(e0: ast::Expr, e1: ast::Expr, loc: Loc) -> ast:
         .with_source_loc(loc)
         .contains_any(e0, e1)
 }
+fn construct_method_is_empty(e: ast::Expr, loc: Loc) -> ast::Expr {
+    ast::ExprBuilder::new().with_source_loc(loc).is_empty(e)
+}
 fn construct_method_get_tag(e0: ast::Expr, e1: ast::Expr, loc: Loc) -> ast::Expr {
     ast::ExprBuilder::new().with_source_loc(loc).get_tag(e0, e1)
 }
@@ -4223,6 +4230,14 @@ mod tests {
                 .exactly_one_underline("[].containsAny(1, 2)")
                 .build(),
             ),
+            (
+                r#"[].isEmpty([])"#,
+                ExpectedErrorMessageBuilder::error(
+                    "call to `isEmpty` requires exactly 0 arguments, but got 1 argument",
+                )
+                .exactly_one_underline("[].isEmpty([])")
+                .build(),
+            ),
             (
                 r#""1.1.1.1".ip()"#,
                 ExpectedErrorMessageBuilder::error("`ip` is a function, not a method")
@@ -4730,7 +4745,7 @@ mod tests {
         let src = "!!!!!!false";
         assert_matches!(parse_expr(src), Err(e) => {
             expect_err(src, &miette::Report::new(e), &ExpectedErrorMessageBuilder::error(
-                "too many occurrences of `!_`",
+                "too many occurrences of `!`",
                 ).help(
                 "cannot chain more the 4 applications of a unary operator"
             ).exactly_one_underline("!!!!!!false").build());
@@ -4738,7 +4753,7 @@ mod tests {
         let src = "-------0";
         assert_matches!(parse_expr(src), Err(e) => {
             expect_err(src, &miette::Report::new(e), &ExpectedErrorMessageBuilder::error(
-                "too many occurrences of `-_`",
+                "too many occurrences of `-`",
                 ).help(
                 "cannot chain more the 4 applications of a unary operator"
             ).exactly_one_underline("-------0").build());
diff --git a/cedar-policy-formatter/Cargo.toml b/cedar-policy-formatter/Cargo.toml
index cf895d68..e66d3537 100644
--- a/cedar-policy-formatter/Cargo.toml
+++ b/cedar-policy-formatter/Cargo.toml
@@ -11,7 +11,7 @@ homepage.workspace = true
 repository.workspace = true
 
 [dependencies]
-cedar-policy-core = { version = "=4.1.0", path = "../cedar-policy-core" }
+cedar-policy-core = { version = "=4.3.0", path = "../cedar-policy-core" }
 pretty = "0.12.1"
 logos = "0.14.3"
 itertools = "0.13"
diff --git a/cedar-policy-validator/Cargo.toml b/cedar-policy-validator/Cargo.toml
index 9dc24adb..6a4b3548 100644
--- a/cedar-policy-validator/Cargo.toml
+++ b/cedar-policy-validator/Cargo.toml
@@ -11,7 +11,7 @@ homepage.workspace = true
 repository.workspace = true
 
 [dependencies]
-cedar-policy-core = { version = "=4.1.0", path = "../cedar-policy-core" }
+cedar-policy-core = { version = "=4.3.0", path = "../cedar-policy-core" }
 serde = { version = "1.0", features = ["derive"] }
 serde_json = { version = "1.0", features = ["preserve_order"] }
 serde_with = "3.0"
@@ -57,7 +57,7 @@ entity-manifest = []
 [dev-dependencies]
 similar-asserts = "1.5.0"
 cool_asserts = "2.0"
-cedar-policy-core = { version = "=4.1.0", path = "../cedar-policy-core", features = ["test-util"] }
+cedar-policy-core = { version = "=4.3.0", path = "../cedar-policy-core", features = ["test-util"] }
 miette = { version = "7.4.0", features = ["fancy"] }
 
 [build-dependencies]
diff --git a/cedar-policy-validator/src/cedar_schema/fmt.rs b/cedar-policy-validator/src/cedar_schema/fmt.rs
index 1102cd44..e23546d1 100644
--- a/cedar-policy-validator/src/cedar_schema/fmt.rs
+++ b/cedar-policy-validator/src/cedar_schema/fmt.rs
@@ -96,8 +96,7 @@ impl<N: Display> Display for json_schema::RecordType<N> {
 
 /// Create a non-empty with borrowed contents from a slice
 fn non_empty_slice<T>(v: &[T]) -> Option<NonEmpty<&T>> {
-    let vs: Vec<&T> = v.iter().collect();
-    NonEmpty::from_vec(vs)
+    NonEmpty::collect(v.iter())
 }
 
 fn fmt_vec<T: Display>(f: &mut std::fmt::Formatter<'_>, ets: NonEmpty<T>) -> std::fmt::Result {
diff --git a/cedar-policy-validator/src/diagnostics/validation_errors.rs b/cedar-policy-validator/src/diagnostics/validation_errors.rs
index 9009b77c..d4485ee2 100644
--- a/cedar-policy-validator/src/diagnostics/validation_errors.rs
+++ b/cedar-policy-validator/src/diagnostics/validation_errors.rs
@@ -212,6 +212,9 @@ pub enum UnexpectedTypeHelp {
     /// Try using `in`
     #[error("try using `in` for entity hierarchy membership")]
     TryUsingIn,
+    /// Try using `== ""`
+    #[error(r#"try using `== ""` to test if a string is empty"#)]
+    TryUsingEqEmptyString,
     /// Cedar doesn't support type tests
     #[error("Cedar only supports run time type tests for entities")]
     TypeTestNotSupported,
diff --git a/cedar-policy-validator/src/entity_manifest.rs b/cedar-policy-validator/src/entity_manifest.rs
index 8a0d7860..209e542a 100644
--- a/cedar-policy-validator/src/entity_manifest.rs
+++ b/cedar-policy-validator/src/entity_manifest.rs
@@ -538,8 +538,19 @@ fn entity_manifest_from_expr(
             .union(&entity_manifest_from_expr(right)?.empty_paths())),
         ExprKind::UnaryApp { op, arg } => {
             match op {
-                // both unary ops are on booleans, so they are simple
+                // these unary ops are on primitive types, so they are simple
                 UnaryOp::Not | UnaryOp::Neg => Ok(entity_manifest_from_expr(arg)?.empty_paths()),
+                UnaryOp::IsEmpty => {
+                    // PANIC SAFETY: Typechecking succeeded, so type annotations are present.
+                    #[allow(clippy::expect_used)]
+                    let ty = arg
+                        .data()
+                        .as_ref()
+                        .expect("Expected annotated types after typechecking");
+                    Ok(entity_manifest_from_expr(arg)?
+                        .full_type_required(ty)
+                        .empty_paths())
+                }
             }
         }
         ExprKind::BinaryApp {
diff --git a/cedar-policy-validator/src/typecheck.rs b/cedar-policy-validator/src/typecheck.rs
index 6b0fbe7f..b488e465 100644
--- a/cedar-policy-validator/src/typecheck.rs
+++ b/cedar-policy-validator/src/typecheck.rs
@@ -2390,6 +2390,28 @@ impl<'a> Typechecker<'a> {
                     )
                 })
             }
+            UnaryOp::IsEmpty => {
+                let ans_arg = self.expect_type(
+                    request_env,
+                    prior_capability,
+                    arg,
+                    Type::any_set(),
+                    type_errors,
+                    |actual| match actual {
+                        Type::Primitive {
+                            primitive_type: Primitive::String,
+                        } => Some(UnexpectedTypeHelp::TryUsingEqEmptyString),
+                        _ => None,
+                    },
+                );
+                ans_arg.then_typecheck(|typ_expr_arg, _| {
+                    TypecheckAnswer::success(
+                        ExprBuilder::with_data(Some(Type::primitive_boolean()))
+                            .with_same_source_loc(unary_expr)
+                            .is_empty(typ_expr_arg),
+                    )
+                })
+            }
         }
     }
 
diff --git a/cedar-policy/CHANGELOG.md b/cedar-policy/CHANGELOG.md
index ff9ef26e..919be6a1 100644
--- a/cedar-policy/CHANGELOG.md
+++ b/cedar-policy/CHANGELOG.md
@@ -15,6 +15,7 @@ Cedar Language Version: TBD
 
 ### Added
 
+- New `.isEmpty()` operator on sets (#1358, resolving #1356)
 - Added protobuf schemas and (de)serialization code using on `prost` crate behind the experimental `protobufs` flag.
 - Added protobuf and JSON generation code to `cedar-policy-cli`.
 - Added a new get helper method to Context that allows easy extraction of generic values from the context by key. This method simplifies the common use case of retrieving values from Context objects.
diff --git a/cedar-policy/Cargo.toml b/cedar-policy/Cargo.toml
index 31e7a403..1a89f778 100644
--- a/cedar-policy/Cargo.toml
+++ b/cedar-policy/Cargo.toml
@@ -11,9 +11,9 @@ homepage.workspace = true
 repository = "https://github.com/cedar-policy/cedar"
 
 [dependencies]
-cedar-policy-core = { version = "=4.1.0", path = "../cedar-policy-core" }
-cedar-policy-validator = { version = "=4.1.0", path = "../cedar-policy-validator" }
-cedar-policy-formatter = { version = "=4.1.0", path = "../cedar-policy-formatter" }
+cedar-policy-core = { version = "=4.3.0", path = "../cedar-policy-core" }
+cedar-policy-validator = { version = "=4.3.0", path = "../cedar-policy-validator" }
+cedar-policy-formatter = { version = "=4.3.0", path = "../cedar-policy-formatter" }
 ref-cast = "1.0"
 serde = { version = "1.0", features = ["derive", "rc"] }
 serde_json = "1.0"
@@ -48,7 +48,7 @@ corpus-timing = []
 
 # Experimental features.
 # Enable all experimental features with `cargo build --features "experimental"`
-experimental = ["partial-eval", "permissive-validate", "partial-validate", "level-validate", "entity-manifest"]
+experimental = ["partial-eval", "permissive-validate", "partial-validate", "level-validate", "entity-manifest", "protobufs"]
 entity-manifest = ["cedar-policy-validator/entity-manifest"]
 partial-eval = ["cedar-policy-core/partial-eval", "cedar-policy-validator/partial-eval"]
 permissive-validate = []
@@ -66,7 +66,7 @@ miette = { version = "7.4.0", features = ["fancy"] }
 cool_asserts = "2.0"
 criterion = "0.5"
 globset = "0.4"
-cedar-policy-core = { version = "=4.1.0", features = [
+cedar-policy-core = { version = "=4.3.0", features = [
     "test-util",
 ], path = "../cedar-policy-core" }
 # NON-CRYPTOGRAPHIC random number generators
diff --git a/cedar-wasm/Cargo.toml b/cedar-wasm/Cargo.toml
index 93abf364..adac855d 100644
--- a/cedar-wasm/Cargo.toml
+++ b/cedar-wasm/Cargo.toml
@@ -9,10 +9,10 @@ license.workspace = true
 exclude = ['/build']
 
 [dependencies]
-cedar-policy = { version = "=4.1.0", path = "../cedar-policy", features = ["wasm"] }
-cedar-policy-core = { version = "=4.1.0", path = "../cedar-policy-core", features = ["wasm"] }
-cedar-policy-formatter = { version = "=4.1.0", path = "../cedar-policy-formatter" }
-cedar-policy-validator = { version = "=4.1.0", path = "../cedar-policy-validator", features = ["wasm"] }
+cedar-policy = { version = "=4.3.0", path = "../cedar-policy", features = ["wasm"] }
+cedar-policy-core = { version = "=4.3.0", path = "../cedar-policy-core", features = ["wasm"] }
+cedar-policy-formatter = { version = "=4.3.0", path = "../cedar-policy-formatter" }
+cedar-policy-validator = { version = "=4.3.0", path = "../cedar-policy-validator", features = ["wasm"] }
 
 serde = { version = "1.0", features = ["derive", "rc"] }
 serde-wasm-bindgen = "0.6"


Test patch:
diff --git a/cedar-policy-cli/tests/sample.rs b/cedar-policy-cli/tests/sample.rs
index 74be7e00..bf2c14d2 100644
--- a/cedar-policy-cli/tests/sample.rs
+++ b/cedar-policy-cli/tests/sample.rs
@@ -19,11 +19,13 @@
 // PANIC SAFETY tests
 #![allow(clippy::unwrap_used)]
 use std::collections::HashMap;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
 
 use cedar_policy::EvalResult;
 use cedar_policy::SlotId;
 use cedar_policy_cli::check_parse;
+use cedar_policy_cli::OptionalSchemaArgs;
+use cedar_policy_cli::SchemaArgs;
 use cedar_policy_cli::SchemaFormat;
 use cedar_policy_cli::{
     authorize, evaluate, link, validate, Arguments, AuthorizeArgs, CedarExitCode, CheckParseArgs,
@@ -87,8 +89,10 @@ fn run_authorize_test_with_linked_policies(
             policy_format: PolicyFormat::Cedar,
             template_linked_file: links_file.map(Into::into),
         },
-        schema_file: None,
-        schema_format: SchemaFormat::default(),
+        schema: OptionalSchemaArgs {
+            schema_file: None,
+            schema_format: SchemaFormat::default(),
+        },
         entities_file: entities_file.into(),
         verbose: true,
         timing: false,
@@ -142,8 +146,10 @@ fn run_authorize_test_context(
             policy_format: PolicyFormat::Cedar,
             template_linked_file: None,
         },
-        schema_file: None,
-        schema_format: SchemaFormat::default(),
+        schema: OptionalSchemaArgs {
+            schema_file: None,
+            schema_format: SchemaFormat::default(),
+        },
         entities_file: entities_file.into(),
         verbose: true,
         timing: false,
@@ -172,8 +178,10 @@ fn run_authorize_test_json(
             policy_format: PolicyFormat::Cedar,
             template_linked_file: None,
         },
-        schema_file: None,
-        schema_format: SchemaFormat::default(),
+        schema: OptionalSchemaArgs {
+            schema_file: None,
+            schema_format: SchemaFormat::default(),
+        },
         entities_file: entities_file.into(),
         verbose: true,
         timing: false,
@@ -554,15 +562,18 @@ fn test_authorize_samples() {
 #[track_caller]
 fn test_validate_samples(
     #[case] policies_file: impl Into<String>,
-    #[case] schema_file: impl Into<String>,
+    #[case] schema_file: impl AsRef<Path>,
     #[case] exit_code: CedarExitCode,
 ) {
     let policies_file = policies_file.into();
-    let schema_file = schema_file.into();
+    let schema_file = schema_file.as_ref();
 
     // Run with JSON schema
     let cmd = ValidateArgs {
-        schema_file: schema_file.clone(),
+        schema: SchemaArgs {
+            schema_file: schema_file.into(),
+            schema_format: SchemaFormat::Json,
+        },
         policies: PoliciesArgs {
             policies_file: Some(policies_file.clone()),
             policy_format: PolicyFormat::Cedar,
@@ -570,17 +581,16 @@ fn test_validate_samples(
         },
         deny_warnings: false,
         validation_mode: cedar_policy_cli::ValidationMode::Strict,
-        schema_format: SchemaFormat::Json,
     };
     let output = validate(&cmd);
     assert_eq!(exit_code, output, "{:#?}", cmd);
 
     // Run with Cedar schema
     let cmd = ValidateArgs {
-        schema_file: schema_file
-            .strip_suffix(".json")
-            .expect("`schema_file` should be the JSON schema")
-            .to_string(),
+        schema: SchemaArgs {
+            schema_file: schema_file.with_extension(""), // remove the `.json` extension to get the filename of the Cedar schema
+            schema_format: SchemaFormat::Cedar,
+        },
         policies: PoliciesArgs {
             policies_file: Some(policies_file),
             policy_format: PolicyFormat::Cedar,
@@ -588,7 +598,6 @@ fn test_validate_samples(
         },
         deny_warnings: false,
         validation_mode: cedar_policy_cli::ValidationMode::Strict,
-        schema_format: SchemaFormat::Cedar,
     };
     let output = validate(&cmd);
     assert_eq!(exit_code, output, "{:#?}", cmd)
@@ -690,8 +699,10 @@ fn test_evaluate_samples(
     #[case] expected: EvalResult,
 ) {
     let cmd = EvaluateArgs {
-        schema_file: None,
-        schema_format: SchemaFormat::default(),
+        schema: OptionalSchemaArgs {
+            schema_file: None,
+            schema_format: SchemaFormat::default(),
+        },
         entities_file: Some(entities_file.into()),
         request: RequestArgs {
             principal: None,
diff --git a/cedar-policy-validator/src/typecheck/test/expr.rs b/cedar-policy-validator/src/typecheck/test/expr.rs
index ae364516..66584821 100644
--- a/cedar-policy-validator/src/typecheck/test/expr.rs
+++ b/cedar-policy-validator/src/typecheck/test/expr.rs
@@ -981,6 +981,32 @@ fn contains_all_typecheck_literals_false() {
     );
 }
 
+#[test]
+fn is_empty_typechecks() {
+    assert_typechecks_empty_schema(
+        Expr::is_empty(Expr::set([Expr::val(1)])),
+        Type::primitive_boolean(),
+    );
+}
+
+#[test]
+fn is_empty_typecheck_fails() {
+    let src = "\"crab\".isEmpty()";
+    let errors =
+        assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean());
+    let error = assert_exactly_one_diagnostic(errors);
+    assert_eq!(
+        error,
+        ValidationError::expected_type(
+            get_loc(src, "\"crab\""),
+            expr_id_placeholder(),
+            Type::any_set(),
+            Type::primitive_string(),
+            Some(UnexpectedTypeHelp::TryUsingEqEmptyString),
+        )
+    )
+}
+
 #[test]
 fn like_typechecks() {
     assert_typechecks_empty_schema(
diff --git a/cedar-policy/src/tests.rs b/cedar-policy/src/tests.rs
index b65f42ed..b1891ef8 100644
--- a/cedar-policy/src/tests.rs
+++ b/cedar-policy/src/tests.rs
@@ -6202,7 +6202,7 @@ mod version_tests {
 
     #[test]
     fn test_sdk_version() {
-        assert_eq!(get_sdk_version().to_string(), "4.1.0");
+        assert_eq!(get_sdk_version().to_string(), "4.3.0");
     }
 
     #[test]
diff --git a/cedar-testing/Cargo.toml b/cedar-testing/Cargo.toml
index b84746d6..809b5491 100644
--- a/cedar-testing/Cargo.toml
+++ b/cedar-testing/Cargo.toml
@@ -6,9 +6,9 @@ license.workspace = true
 publish = false
 
 [dependencies]
-cedar-policy = { version = "=4.1.0", path = "../cedar-policy" }
-cedar-policy-core = { version = "=4.1.0", path = "../cedar-policy-core" }
-cedar-policy-validator = { version = "=4.1.0", path = "../cedar-policy-validator" }
+cedar-policy = { version = "=4.3.0", path = "../cedar-policy" }
+cedar-policy-core = { version = "=4.3.0", path = "../cedar-policy-core" }
+cedar-policy-validator = { version = "=4.3.0", path = "../cedar-policy-validator" }
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 smol_str = { version = "0.3", features = ["serde"] }