Repository: cedar-policy/cedar
Pull request #1316 (2024-11-11 22:09:28)

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


shaobo-he-aws: > * Doesn't handle `@foo` empty annotation syntax

I did do it but realized that it wasn't in the RFC.

john-h-kastner-aws: > > * Doesn't handle `@foo` empty annotation syntax
> 
> I did do it but realized that it wasn't in the RFC.

fair, but empty annotation came about after the RFC was written, so I'd assume the intent of the RFC was to support annotations in schema as they exist for policies 

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 89222650..dbdb44df 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -240,9 +240,9 @@ dependencies = [
 
 [[package]]
 name = "bstr"
-version = "1.11.0"
+version = "1.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a68f1f47cdf0ec8ee4b941b2eee2a80cb796db73118c0dd09ac63fbe405be22"
+checksum = "786a307d683a5bf92e6fd5fd69a7eb613751668d1d8d67d802846dfe367c62c8"
 dependencies = [
  "memchr",
  "regex-automata",
@@ -287,9 +287,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
 
 [[package]]
 name = "cc"
-version = "1.2.3"
+version = "1.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27f657647bcff5394bf56c7317665bbf790a137a50eaaa5c6bfbb9e27a518f2d"
+checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf"
 dependencies = [
  "shlex",
 ]
@@ -320,7 +320,7 @@ dependencies = [
  "serde_json",
  "serde_with",
  "smol_str",
- "thiserror 2.0.6",
+ "thiserror 2.0.7",
  "tsify",
  "wasm-bindgen",
 ]
@@ -345,7 +345,7 @@ dependencies = [
  "serde",
  "serde_json",
  "tempfile",
- "thiserror 2.0.6",
+ "thiserror 2.0.7",
 ]
 
 [[package]]
@@ -374,7 +374,7 @@ dependencies = [
  "serde_with",
  "smol_str",
  "stacker",
- "thiserror 2.0.6",
+ "thiserror 2.0.7",
  "tsify",
  "wasm-bindgen",
 ]
@@ -417,7 +417,7 @@ dependencies = [
  "similar-asserts",
  "smol_str",
  "stacker",
- "thiserror 2.0.6",
+ "thiserror 2.0.7",
  "tsify",
  "unicode-security",
  "wasm-bindgen",
@@ -641,9 +641,9 @@ dependencies = [
 
 [[package]]
 name = "crossbeam-deque"
-version = "0.8.5"
+version = "0.8.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
 dependencies = [
  "crossbeam-epoch",
  "crossbeam-utils",
@@ -660,9 +660,9 @@ dependencies = [
 
 [[package]]
 name = "crossbeam-utils"
-version = "0.8.20"
+version = "0.8.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
 
 [[package]]
 name = "crunchy"
@@ -2140,9 +2140,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
 
 [[package]]
 name = "semver"
-version = "1.0.23"
+version = "1.0.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
+checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba"
 dependencies = [
  "serde",
 ]
@@ -2404,9 +2404,9 @@ dependencies = [
 
 [[package]]
 name = "term"
-version = "1.0.0"
+version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4df4175de05129f31b80458c6df371a15e7fc3fd367272e6bf938e5c351c7ea0"
+checksum = "a3bb6001afcea98122260987f8b7b5da969ecad46dbf0b5453702f776b491a41"
 dependencies = [
  "home",
  "windows-sys 0.52.0",
@@ -2449,11 +2449,11 @@ dependencies = [
 
 [[package]]
 name = "thiserror"
-version = "2.0.6"
+version = "2.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fec2a1820ebd077e2b90c4df007bebf344cd394098a13c563957d0afc83ea47"
+checksum = "93605438cbd668185516ab499d589afb7ee1859ea3d5fc8f6b0755e1c7443767"
 dependencies = [
- "thiserror-impl 2.0.6",
+ "thiserror-impl 2.0.7",
 ]
 
 [[package]]
@@ -2469,9 +2469,9 @@ dependencies = [
 
 [[package]]
 name = "thiserror-impl"
-version = "2.0.6"
+version = "2.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d65750cab40f4ff1929fb1ba509e9914eb756131cef4210da8d5d700d26f6312"
+checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36"
 dependencies = [
  "proc-macro2",
  "quote",
diff --git a/cedar-policy-cli/Cargo.toml b/cedar-policy-cli/Cargo.toml
index e64dd98a..6c7a2514 100644
--- a/cedar-policy-cli/Cargo.toml
+++ b/cedar-policy-cli/Cargo.toml
@@ -20,7 +20,7 @@ serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 miette = { version = "7.4.0", features = ["fancy"] }
 thiserror = "2.0"
-semver = "1.0.23"
+semver = "1.0.24"
 prost = {version = "0.13", optional = true}
 
 [build-dependencies]
diff --git a/cedar-policy-core/src/ast.rs b/cedar-policy-core/src/ast.rs
index 7e683390..7b6cbe1e 100644
--- a/cedar-policy-core/src/ast.rs
+++ b/cedar-policy-core/src/ast.rs
@@ -56,3 +56,5 @@ mod value;
 pub use value::*;
 mod expr_iterator;
 pub use expr_iterator::*;
+mod annotation;
+pub use annotation::*;
diff --git a/cedar-policy-core/src/ast/annotation.rs b/cedar-policy-core/src/ast/annotation.rs
new file mode 100644
index 00000000..ac7b2eb3
--- /dev/null
+++ b/cedar-policy-core/src/ast/annotation.rs
@@ -0,0 +1,167 @@
+/*
+ * Copyright Cedar Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+use std::collections::BTreeMap;
+
+use educe::Educe;
+use serde::{Deserialize, Serialize};
+use smol_str::SmolStr;
+
+use crate::parser::Loc;
+
+use super::AnyId;
+
+/// Struct which holds the annotations for a policy
+#[derive(Serialize, Deserialize, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Debug)]
+pub struct Annotations(
+    #[serde(default)]
+    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
+    #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")]
+    BTreeMap<AnyId, Annotation>,
+);
+
+impl std::fmt::Display for Annotations {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        for (k, v) in &self.0 {
+            writeln!(f, "@{k}({v})")?
+        }
+        Ok(())
+    }
+}
+
+impl Annotations {
+    /// Create a new empty `Annotations` (with no annotations)
+    pub fn new() -> Self {
+        Self(BTreeMap::new())
+    }
+
+    /// Get an annotation by key
+    pub fn get(&self, key: &AnyId) -> Option<&Annotation> {
+        self.0.get(key)
+    }
+
+    /// Iterate over all annotations
+    pub fn iter(&self) -> impl Iterator<Item = (&AnyId, &Annotation)> {
+        self.0.iter()
+    }
+
+    /// Tell if it's empty
+    pub fn is_empty(&self) -> bool {
+        self.0.is_empty()
+    }
+}
+
+/// Wraps the [`BTreeMap`] into an opaque type so we can change it later if need be
+#[derive(Debug)]
+pub struct IntoIter(std::collections::btree_map::IntoIter<AnyId, Annotation>);
+
+impl Iterator for IntoIter {
+    type Item = (AnyId, Annotation);
+
+    fn next(&mut self) -> Option<Self::Item> {
+        self.0.next()
+    }
+}
+
+impl IntoIterator for Annotations {
+    type Item = (AnyId, Annotation);
+
+    type IntoIter = IntoIter;
+
+    fn into_iter(self) -> Self::IntoIter {
+        IntoIter(self.0.into_iter())
+    }
+}
+
+impl Default for Annotations {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl FromIterator<(AnyId, Annotation)> for Annotations {
+    fn from_iter<T: IntoIterator<Item = (AnyId, Annotation)>>(iter: T) -> Self {
+        Self(BTreeMap::from_iter(iter))
+    }
+}
+
+impl From<BTreeMap<AnyId, Annotation>> for Annotations {
+    fn from(value: BTreeMap<AnyId, Annotation>) -> Self {
+        Self(value)
+    }
+}
+
+/// Struct which holds the value of a particular annotation
+#[derive(Educe, Clone, Debug, Serialize, Deserialize, Default)]
+#[educe(Hash, PartialEq, Eq, PartialOrd, Ord)]
+#[serde(transparent)]
+#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
+#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
+pub struct Annotation {
+    /// Annotation value
+    pub val: SmolStr,
+    /// Source location. Note this is the location of _the entire key-value
+    /// pair_ for the annotation, not just `val` above
+    #[serde(skip)]
+    #[educe(Hash(ignore))]
+    #[educe(PartialEq(ignore))]
+    #[educe(PartialOrd(ignore))]
+    pub loc: Option<Loc>,
+}
+
+impl std::fmt::Display for Annotation {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "\"{}\"", self.val.escape_debug())
+    }
+}
+
+impl Annotation {
+    /// Construct an Annotation with an optional value.  This function is used
+    /// to construct annotations from the CST and EST representation where a
+    /// value is not required, but an absent value is equivalent to `""`.
+    /// Here, a `None` constructs an annotation containing the value `""`.`
+    pub fn with_optional_value(val: Option<SmolStr>, loc: Option<Loc>) -> Self {
+        Self {
+            val: val.unwrap_or_default(),
+            loc,
+        }
+    }
+}
+
+impl AsRef<str> for Annotation {
+    fn as_ref(&self) -> &str {
+        &self.val
+    }
+}
+
+#[cfg(feature = "protobufs")]
+impl From<&crate::ast::proto::Annotation> for Annotation {
+    fn from(v: &crate::ast::proto::Annotation) -> Self {
+        Self {
+            val: v.val.clone().into(),
+            loc: None,
+        }
+    }
+}
+
+#[cfg(feature = "protobufs")]
+impl From<&Annotation> for crate::ast::proto::Annotation {
+    fn from(v: &Annotation) -> Self {
+        Self {
+            val: v.val.to_string(),
+        }
+    }
+}
diff --git a/cedar-policy-core/src/ast/id.rs b/cedar-policy-core/src/ast/id.rs
index 37f230a4..5dc3c5f9 100644
--- a/cedar-policy-core/src/ast/id.rs
+++ b/cedar-policy-core/src/ast/id.rs
@@ -262,6 +262,8 @@ impl<'a> arbitrary::Arbitrary<'a> for UnreservedId {
 //
 // For now, internally, `AnyId`s are just owned `SmolString`s.
 #[derive(Serialize, Debug, PartialEq, Eq, Clone, Hash, PartialOrd, Ord)]
+#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
+#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
 pub struct AnyId(SmolStr);
 
 impl AnyId {
diff --git a/cedar-policy-core/src/ast/policy.rs b/cedar-policy-core/src/ast/policy.rs
index 22a75ceb..a0f4f2c1 100644
--- a/cedar-policy-core/src/ast/policy.rs
+++ b/cedar-policy-core/src/ast/policy.rs
@@ -16,13 +16,13 @@
 
 use crate::ast::*;
 use crate::parser::Loc;
+use annotation::{Annotation, Annotations};
 use educe::Educe;
 use itertools::Itertools;
 use miette::Diagnostic;
 use nonempty::{nonempty, NonEmpty};
 use serde::{Deserialize, Serialize};
-use smol_str::{SmolStr, ToSmolStr};
-use std::collections::BTreeMap;
+use smol_str::SmolStr;
 use std::{collections::HashMap, sync::Arc};
 use thiserror::Error;
 
@@ -1197,9 +1197,7 @@ impl From<StaticPolicy> for TemplateBody {
 
 impl std::fmt::Display for TemplateBody {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        for (k, v) in self.annotations.iter() {
-            writeln!(f, "@{}(\"{}\")", k, v.val.escape_debug())?
-        }
+        self.annotations.fmt(f)?;
         write!(
             f,
             "{}(\n  {},\n  {},\n  {}\n) when {{\n  {}\n}};",
@@ -1296,119 +1294,6 @@ impl From<&Template> for proto::TemplateBody {
     }
 }
 
-/// Struct which holds the annotations for a policy
-#[derive(Serialize, Deserialize, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Debug)]
-pub struct Annotations(BTreeMap<AnyId, Annotation>);
-
-impl Annotations {
-    /// Create a new empty `Annotations` (with no annotations)
-    pub fn new() -> Self {
-        Self(BTreeMap::new())
-    }
-
-    /// Get an annotation by key
-    pub fn get(&self, key: &AnyId) -> Option<&Annotation> {
-        self.0.get(key)
-    }
-
-    /// Iterate over all annotations
-    pub fn iter(&self) -> impl Iterator<Item = (&AnyId, &Annotation)> {
-        self.0.iter()
-    }
-}
-
-/// Wraps the [`BTreeMap`]` into an opaque type so we can change it later if need be
-#[derive(Debug)]
-pub struct IntoIter(std::collections::btree_map::IntoIter<AnyId, Annotation>);
-
-impl Iterator for IntoIter {
-    type Item = (AnyId, Annotation);
-
-    fn next(&mut self) -> Option<Self::Item> {
-        self.0.next()
-    }
-}
-
-impl IntoIterator for Annotations {
-    type Item = (AnyId, Annotation);
-
-    type IntoIter = IntoIter;
-
-    fn into_iter(self) -> Self::IntoIter {
-        IntoIter(self.0.into_iter())
-    }
-}
-
-impl Default for Annotations {
-    fn default() -> Self {
-        Self::new()
-    }
-}
-
-impl FromIterator<(AnyId, Annotation)> for Annotations {
-    fn from_iter<T: IntoIterator<Item = (AnyId, Annotation)>>(iter: T) -> Self {
-        Self(BTreeMap::from_iter(iter))
-    }
-}
-
-impl From<BTreeMap<AnyId, Annotation>> for Annotations {
-    fn from(value: BTreeMap<AnyId, Annotation>) -> Self {
-        Self(value)
-    }
-}
-
-/// Struct which holds the value of a particular annotation
-#[derive(Educe, Serialize, Deserialize, Clone, Debug)]
-#[educe(PartialEq, Eq, Hash, PartialOrd, Ord)]
-pub struct Annotation {
-    /// Annotation value
-    pub val: SmolStr,
-    /// Source location. Note this is the location of _the entire key-value
-    /// pair_ for the annotation, not just `val` above
-    #[educe(PartialEq(ignore))]
-    #[educe(Hash(ignore))]
-    #[educe(PartialOrd(ignore))]
-    pub loc: Option<Loc>,
-}
-
-impl Annotation {
-    /// Construct an Annotation with an optional value.  This function is used
-    /// to construct annotations from the CST and EST representation where a
-    /// value is not required, but an absent value is equivalent to `""`.
-    /// Here, a `None` constructs an annotation containing the value `""`.`
-    pub fn with_optional_value(val: Option<SmolStr>, loc: Option<Loc>) -> Self {
-        Self {
-            val: val.unwrap_or_else(|| "".to_smolstr()),
-            loc,
-        }
-    }
-}
-
-impl AsRef<str> for Annotation {
-    fn as_ref(&self) -> &str {
-        &self.val
-    }
-}
-
-#[cfg(feature = "protobufs")]
-impl From<&proto::Annotation> for Annotation {
-    fn from(v: &proto::Annotation) -> Self {
-        Self {
-            val: v.val.clone().into(),
-            loc: None,
-        }
-    }
-}
-
-#[cfg(feature = "protobufs")]
-impl From<&Annotation> for proto::Annotation {
-    fn from(v: &Annotation) -> Self {
-        Self {
-            val: v.val.to_string(),
-        }
-    }
-}
-
 /// Template constraint on principal scope variables
 #[derive(Serialize, Deserialize, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Debug)]
 pub struct PrincipalConstraint {
diff --git a/cedar-policy-core/src/ast/policy_set.rs b/cedar-policy-core/src/ast/policy_set.rs
index e5a6d8d6..af844f8a 100644
--- a/cedar-policy-core/src/ast/policy_set.rs
+++ b/cedar-policy-core/src/ast/policy_set.rs
@@ -597,7 +597,8 @@ mod test {
     use super::*;
     use crate::{
         ast::{
-            ActionConstraint, Annotations, Effect, Expr, PrincipalConstraint, ResourceConstraint,
+            annotation::Annotations, ActionConstraint, Effect, Expr, PrincipalConstraint,
+            ResourceConstraint,
         },
         parser,
     };
diff --git a/cedar-policy-core/src/authorizer.rs b/cedar-policy-core/src/authorizer.rs
index 9d3aaab6..bb9ffe88 100644
--- a/cedar-policy-core/src/authorizer.rs
+++ b/cedar-policy-core/src/authorizer.rs
@@ -180,6 +180,8 @@ impl std::fmt::Debug for Authorizer {
 #[allow(clippy::panic)]
 #[cfg(test)]
 mod test {
+    use crate::ast::Annotations;
+
     use super::*;
     use crate::parser;
 
diff --git a/cedar-policy-core/src/est.rs b/cedar-policy-core/src/est.rs
index bff81c47..6c7cadc9 100644
--- a/cedar-policy-core/src/est.rs
+++ b/cedar-policy-core/src/est.rs
@@ -24,16 +24,17 @@ mod policy_set;
 pub use policy_set::*;
 mod scope_constraints;
 pub use scope_constraints::*;
+mod annotation;
+pub use annotation::*;
 
-use crate::ast;
 use crate::ast::EntityUID;
+use crate::ast::{self, Annotation};
 use crate::entities::json::{err::JsonDeserializationError, EntityUidJson};
 use crate::parser::cst;
 use crate::parser::err::{parse_errors, ParseErrors, ToASTError, ToASTErrorKind};
 use crate::parser::util::{flatten_tuple_2, flatten_tuple_4};
 use serde::{Deserialize, Serialize};
 use serde_with::serde_as;
-use smol_str::SmolStr;
 use std::collections::{BTreeMap, HashMap};
 
 #[cfg(feature = "wasm")]
@@ -64,10 +65,8 @@ pub struct Policy {
     conditions: Vec<Clause>,
     /// annotations
     #[serde(default)]
-    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
-    #[serde_as(as = "serde_with::MapPreventDuplicates<_,_>")]
-    #[cfg_attr(feature = "wasm", tsify(type = "Record<string, string>"))]
-    annotations: BTreeMap<ast::AnyId, Option<SmolStr>>,
+    #[serde(skip_serializing_if = "Annotations::is_empty")]
+    annotations: Annotations,
 }
 
 /// Serde JSON structure for a `when` or `unless` clause in the EST format
@@ -151,7 +150,12 @@ impl TryFrom<cst::Policy> for Policy {
     fn try_from(policy: cst::Policy) -> Result<Policy, ParseErrors> {
         let maybe_effect = policy.effect.to_effect();
         let maybe_scope = policy.extract_scope();
-        let maybe_annotations = policy.get_ast_annotations(|v, _| v);
+        let maybe_annotations = policy.get_ast_annotations(|v, l| {
+            Some(Annotation {
+                val: v?,
+                loc: Some(l.clone()),
+            })
+        });
         let maybe_conditions = ParseErrors::transpose(policy.conds.into_iter().map(|node| {
             let (cond, loc) = node.into_inner();
             let cond = cond.ok_or_else(|| {
@@ -172,7 +176,7 @@ impl TryFrom<cst::Policy> for Policy {
             action: action.into(),
             resource: resource.into(),
             conditions,
-            annotations,
+            annotations: Annotations(annotations),
         })
     }
 }
@@ -261,8 +265,14 @@ impl Policy {
             id,
             None,
             self.annotations
+                .0
                 .into_iter()
-                .map(|(key, val)| (key, ast::Annotation::with_optional_value(val, None)))
+                .map(|(key, val)| {
+                    (
+                        key,
+                        ast::Annotation::with_optional_value(val.map(|v| v.val), None),
+                    )
+                })
                 .collect(),
             self.effect,
             self.principal.try_into()?,
@@ -306,13 +316,14 @@ impl From<ast::Policy> for Policy {
             action: ast.action_constraint().clone().into(),
             resource: ast.resource_constraint().into(),
             conditions: vec![ast.non_scope_constraints().clone().into()],
-            annotations: ast
-                .annotations()
-                // When converting from AST to EST, we will always interpret an
-                // empty-string annotation as an explicit `""` rather than
-                // `null` (which is implicitly equivalent to `""`).
-                .map(|(k, v)| (k.clone(), Some(v.val.clone())))
-                .collect(),
+            annotations: Annotations(
+                ast.annotations()
+                    // When converting from AST to EST, we will always interpret an
+                    // empty-string annotation as an explicit `""` rather than
+                    // `null` (which is implicitly equivalent to `""`).
+                    .map(|(k, v)| (k.clone(), Some(v.clone())))
+                    .collect(),
+            ),
         }
     }
 }
@@ -326,13 +337,14 @@ impl From<ast::Template> for Policy {
             action: ast.action_constraint().clone().into(),
             resource: ast.resource_constraint().clone().into(),
             conditions: vec![ast.non_scope_constraints().clone().into()],
-            annotations: ast
-                .annotations()
-                // When converting from AST to EST, we will always interpret an
-                // empty-string annotation as an explicit `""` rather than
-                // `null` (which is implicitly equivalent to `""`)
-                .map(|(k, v)| (k.clone(), Some(v.val.clone())))
-                .collect(),
+            annotations: Annotations(
+                ast.annotations()
+                    // When converting from AST to EST, we will always interpret an
+                    // empty-string annotation as an explicit `""` rather than
+                    // `null` (which is implicitly equivalent to `""`)
+                    .map(|(k, v)| (k.clone(), Some(v.clone())))
+                    .collect(),
+            ),
         }
     }
 }
@@ -345,10 +357,10 @@ impl<T: Clone> From<ast::Expr<T>> for Clause {
 
 impl std::fmt::Display for Policy {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        for (k, v) in self.annotations.iter() {
+        for (k, v) in self.annotations.0.iter() {
             write!(f, "@{k}")?;
             if let Some(v) = v {
-                write!(f, "(\"{}\")", v.escape_debug())?;
+                write!(f, "({v})")?;
             }
             writeln!(f)?;
         }
diff --git a/cedar-policy-core/src/est/annotation.rs b/cedar-policy-core/src/est/annotation.rs
new file mode 100644
index 00000000..eb4df1e4
--- /dev/null
+++ b/cedar-policy-core/src/est/annotation.rs
@@ -0,0 +1,86 @@
+/*
+ * Copyright Cedar Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::ast::{self, Annotation, AnyId};
+#[cfg(feature = "wasm")]
+extern crate tsify;
+
+/// Similar to [`ast::Annotations`] but allow annotation value to be `null`
+#[derive(Serialize, Deserialize, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Debug)]
+#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
+pub struct Annotations(
+    #[serde(default)]
+    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
+    #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")]
+    #[cfg_attr(feature = "wasm", tsify(type = "Record<string, Annotation>"))]
+    pub BTreeMap<AnyId, Option<Annotation>>,
+);
+
+impl Annotations {
+    /// Create a new empty `Annotations` (with no annotations)
+    pub fn new() -> Self {
+        Self(BTreeMap::new())
+    }
+    /// Tell if it's empty
+    pub fn is_empty(&self) -> bool {
+        self.0.is_empty()
+    }
+}
+
+impl From<Annotations> for ast::Annotations {
+    fn from(value: Annotations) -> Self {
+        ast::Annotations::from_iter(
+            value
+                .0
+                .into_iter()
+                .map(|(key, value)| (key, value.unwrap_or_default())),
+        )
+    }
+}
+
+impl From<ast::Annotations> for Annotations {
+    fn from(value: ast::Annotations) -> Self {
+        Self(
+            value
+                .into_iter()
+                .map(|(key, value)| (key, Some(value)))
+                .collect(),
+        )
+    }
+}
+
+impl std::fmt::Display for Annotations {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        for (k, v) in &self.0 {
+            if let Some(anno) = v {
+                writeln!(f, "@{k}({anno})")?
+            } else {
+                writeln!(f, "@{k}")?
+            }
+        }
+        Ok(())
+    }
+}
+
+impl Default for Annotations {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/cedar-policy-core/src/parser/node.rs b/cedar-policy-core/src/parser/node.rs
index 3b626962..5b63457b 100644
--- a/cedar-policy-core/src/parser/node.rs
+++ b/cedar-policy-core/src/parser/node.rs
@@ -25,13 +25,14 @@ use super::loc::Loc;
 
 /// Metadata for our syntax trees
 #[derive(Educe, Debug, Clone, Deserialize, Serialize)]
-#[educe(PartialEq, Eq, Hash)]
+#[educe(PartialEq, Eq, Hash, Ord, PartialOrd)]
 pub struct Node<T> {
     /// Main data represented
     pub node: T,
 
     /// Source location
     #[educe(PartialEq(ignore))]
+    #[educe(PartialOrd(ignore))]
     #[educe(Hash(ignore))]
     pub loc: Loc,
 }
diff --git a/cedar-policy-validator/src/cedar_schema/ast.rs b/cedar-policy-validator/src/cedar_schema/ast.rs
index 6bac99dd..9279c28a 100644
--- a/cedar-policy-validator/src/cedar_schema/ast.rs
+++ b/cedar-policy-validator/src/cedar_schema/ast.rs
@@ -14,10 +14,10 @@
  * limitations under the License.
  */
 
-use std::iter::once;
+use std::{collections::BTreeMap, iter::once};
 
 use cedar_policy_core::{
-    ast::{Id, InternalName},
+    ast::{Annotation, Annotations, AnyId, Id, InternalName},
     parser::{Loc, Node},
 };
 use itertools::{Either, Itertools};
@@ -29,11 +29,54 @@ use smol_str::ToSmolStr;
 
 use crate::json_schema;
 
+use super::err::UserError;
+
 pub const BUILTIN_TYPES: [&str; 3] = ["Long", "String", "Bool"];
 
 pub(super) const CEDAR_NAMESPACE: &str = "__cedar";
 
-pub type Schema = Vec<Node<Namespace>>;
+/// A struct that can be annotated, e.g., entity types.
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Annotated<T> {
+    /// The struct that's optionally annotated
+    pub data: T,
+    /// Annotations
+    pub annotations: Annotations,
+}
+
+pub type Schema = Vec<Annotated<Namespace>>;
+
+pub fn deduplicate_annotations<T>(
+    data: T,
+    annotations: Vec<Node<(Node<AnyId>, Option<Node<SmolStr>>)>>,
+) -> Result<Annotated<T>, UserError> {
+    let mut unique_annotations: BTreeMap<Node<AnyId>, Option<Node<SmolStr>>> = BTreeMap::new();
+    for annotation in annotations {
+        let (key, value) = annotation.node;
+        if let Some((old_key, _)) = unique_annotations.get_key_value(&key) {
+            return Err(UserError::DuplicateAnnotations(
+                key.node,
+                Node::with_source_loc((), old_key.loc.clone()),
+                Node::with_source_loc((), key.loc),
+            ));
+        } else {
+            unique_annotations.insert(key, value);
+        }
+    }
+    Ok(Annotated {
+        data,
+        annotations: unique_annotations
+            .into_iter()
+            .map(|(key, value)| {
+                let (val, loc) = match value {
+                    Some(n) => (Some(n.node), Some(n.loc)),
+                    None => (None, None),
+                };
+                (key.node, Annotation::with_optional_value(val, loc))
+            })
+            .collect(),
+    })
+}
 
 /// A path is a non empty list of identifiers that forms a namespace + type
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -169,9 +212,9 @@ impl QualName {
 #[derive(Debug, Clone)]
 pub struct Namespace {
     /// The name of this namespace. If [`None`], then this is the unqualified namespace
-    pub name: Option<Node<Path>>,
+    pub name: Option<Path>,
     /// The [`Declaration`]s contained in this namespace
-    pub decls: Vec<Node<Declaration>>,
+    pub decls: Vec<Annotated<Node<Declaration>>>,
 }
 
 impl Namespace {
@@ -215,7 +258,7 @@ pub struct EntityDecl {
     /// Entity Types this type is allowed to be related to via the `in` relation
     pub member_of_types: Vec<Path>,
     /// Attributes this entity has
-    pub attrs: Vec<Node<AttrDecl>>,
+    pub attrs: Vec<Node<Annotated<AttrDecl>>>,
     /// Tag type for this entity (`None` means no tags on this entity)
     pub tags: Option<Node<Type>>,
 }
@@ -228,7 +271,7 @@ pub enum Type {
     /// A [`Path`] that could either refer to a Common Type or an Entity Type
     Ident(Path),
     /// A Record
-    Record(Vec<Node<AttrDecl>>),
+    Record(Vec<Node<Annotated<AttrDecl>>>),
 }
 
 /// Primitive Type Definitions
@@ -297,7 +340,7 @@ pub enum AppDecl {
     /// Constraints on the `principal` or `resource`
     PR(PRAppDecl),
     /// Constraints on the `context`
-    Context(Either<Path, Vec<Node<AttrDecl>>>),
+    Context(Either<Path, Vec<Node<Annotated<AttrDecl>>>>),
 }
 
 /// An action declaration
diff --git a/cedar-policy-validator/src/cedar_schema/err.rs b/cedar-policy-validator/src/cedar_schema/err.rs
index bbe7dcc4..394f759f 100644
--- a/cedar-policy-validator/src/cedar_schema/err.rs
+++ b/cedar-policy-validator/src/cedar_schema/err.rs
@@ -23,6 +23,7 @@ use std::{
 };
 
 use cedar_policy_core::{
+    ast::AnyId,
     impl_diagnostic_from_source_loc_field, impl_diagnostic_from_two_source_loc_fields,
     impl_diagnostic_from_two_source_loc_opt_fields,
     parser::{
@@ -43,21 +44,35 @@ use super::ast::PR;
 #[derive(Debug, Clone, PartialEq, Eq, Error)]
 pub enum UserError {
     #[error("An empty list was passed")]
-    EmptyList,
+    EmptyList(Node<()>),
     #[error("Invalid escape codes")]
-    StringEscape(NonEmpty<UnescapeError>),
+    StringEscape(Node<NonEmpty<UnescapeError>>),
     #[error("`{0}` is a reserved identifier")]
-    ReservedIdentifierUsed(SmolStr),
+    ReservedIdentifierUsed(Node<SmolStr>),
+    #[error("duplicate annotations: `{}`", .0)]
+    DuplicateAnnotations(AnyId, Node<()>, Node<()>),
+}
+
+impl UserError {
+    // Extract a primary source span locating the error.
+    pub(crate) fn primary_source_span(&self) -> SourceSpan {
+        match self {
+            Self::EmptyList(n) => n.loc.span,
+            Self::StringEscape(n) => n.loc.span,
+            Self::ReservedIdentifierUsed(n) => n.loc.span,
+            // use the first occurrence as the primary source span
+            Self::DuplicateAnnotations(_, n, _) => n.loc.span,
+        }
+    }
 }
 
 pub(crate) type RawLocation = usize;
 pub(crate) type RawToken<'a> = lalr::lexer::Token<'a>;
-pub(crate) type RawUserError = Node<UserError>;
 
-pub(crate) type RawParseError<'a> = lalr::ParseError<RawLocation, RawToken<'a>, RawUserError>;
-pub(crate) type RawErrorRecovery<'a> = lalr::ErrorRecovery<RawLocation, RawToken<'a>, RawUserError>;
+pub(crate) type RawParseError<'a> = lalr::ParseError<RawLocation, RawToken<'a>, UserError>;
+pub(crate) type RawErrorRecovery<'a> = lalr::ErrorRecovery<RawLocation, RawToken<'a>, UserError>;
 
-type OwnedRawParseError = lalr::ParseError<RawLocation, String, RawUserError>;
+type OwnedRawParseError = lalr::ParseError<RawLocation, String, UserError>;
 
 lazy_static! {
     static ref SCHEMA_TOKEN_CONFIG: ExpectedTokenConfig = ExpectedTokenConfig {
@@ -102,7 +117,7 @@ lazy_static! {
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct ParseError {
     /// Error generated by lalrpop
-    err: OwnedRawParseError,
+    pub(crate) err: OwnedRawParseError,
     /// Source code
     src: Arc<str>,
 }
@@ -133,7 +148,7 @@ impl ParseError {
             OwnedRawParseError::ExtraToken {
                 token: (token_start, _, token_end),
             } => SourceSpan::from(*token_start..*token_end),
-            OwnedRawParseError::User { error } => error.loc.span,
+            OwnedRawParseError::User { error } => error.primary_source_span(),
         }
     }
 }
@@ -152,9 +167,7 @@ impl Display for ParseError {
                 token: (_, token, _),
                 ..
             } => write!(f, "extra token `{token}`"),
-            OwnedRawParseError::User {
-                error: Node { node, .. },
-            } => write!(f, "{node}"),
+            OwnedRawParseError::User { error } => write!(f, "{error}"),
         }
     }
 }
@@ -168,27 +181,42 @@ impl Diagnostic for ParseError {
 
     fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
         let primary_source_span = self.primary_source_span();
-        let Self { err, .. } = self;
-        let labeled_span = match err {
-            OwnedRawParseError::InvalidToken { .. } => LabeledSpan::underline(primary_source_span),
-            OwnedRawParseError::UnrecognizedEof { expected, .. } => LabeledSpan::new_with_span(
-                expected_to_string(expected, &SCHEMA_TOKEN_CONFIG),
-                primary_source_span,
-            ),
-            OwnedRawParseError::UnrecognizedToken { expected, .. } => LabeledSpan::new_with_span(
-                expected_to_string(expected, &SCHEMA_TOKEN_CONFIG),
-                primary_source_span,
-            ),
-            OwnedRawParseError::ExtraToken { .. } => LabeledSpan::underline(primary_source_span),
-            OwnedRawParseError::User { .. } => LabeledSpan::underline(primary_source_span),
-        };
-        Some(Box::new(std::iter::once(labeled_span)))
+        match &self.err {
+            OwnedRawParseError::InvalidToken { .. } => Some(Box::new(std::iter::once(
+                LabeledSpan::underline(primary_source_span),
+            ))),
+            OwnedRawParseError::UnrecognizedEof { expected, .. } => {
+                Some(Box::new(std::iter::once(LabeledSpan::new_with_span(
+                    expected_to_string(expected, &SCHEMA_TOKEN_CONFIG),
+                    primary_source_span,
+                ))))
+            }
+            OwnedRawParseError::UnrecognizedToken { expected, .. } => {
+                Some(Box::new(std::iter::once(LabeledSpan::new_with_span(
+                    expected_to_string(expected, &SCHEMA_TOKEN_CONFIG),
+                    primary_source_span,
+                ))))
+            }
+            OwnedRawParseError::ExtraToken { .. } => Some(Box::new(std::iter::once(
+                LabeledSpan::underline(primary_source_span),
+            ))),
+            OwnedRawParseError::User {
+                error: UserError::DuplicateAnnotations(_, n1, n2),
+            } => Some(Box::new(
+                std::iter::once(n1.loc.span)
+                    .chain(std::iter::once(n2.loc.span))
+                    .map(LabeledSpan::underline),
+            )),
+            OwnedRawParseError::User { .. } => Some(Box::new(std::iter::once(
+                LabeledSpan::underline(primary_source_span),
+            ))),
+        }
     }
 }
 
 /// Multiple parse errors.
 #[derive(Clone, Debug, PartialEq, Eq)]
-pub struct ParseErrors(Box<NonEmpty<ParseError>>);
+pub struct ParseErrors(pub(crate) Box<NonEmpty<ParseError>>);
 
 impl ParseErrors {
     pub fn new(first: ParseError, tail: impl IntoIterator<Item = ParseError>) -> Self {
diff --git a/cedar-policy-validator/src/cedar_schema/fmt.rs b/cedar-policy-validator/src/cedar_schema/fmt.rs
index e23546d1..5f20f11e 100644
--- a/cedar-policy-validator/src/cedar_schema/fmt.rs
+++ b/cedar-policy-validator/src/cedar_schema/fmt.rs
@@ -32,7 +32,7 @@ impl<N: Display> Display for json_schema::Fragment<N> {
         for (ns, def) in &self.0 {
             match ns {
                 None => write!(f, "{def}")?,
-                Some(ns) => write!(f, "namespace {ns} {{\n{def}}}\n")?,
+                Some(ns) => write!(f, "{}namespace {ns} {{\n{}}}\n", def.annotations, def)?,
             }
         }
         Ok(())
@@ -42,13 +42,13 @@ impl<N: Display> Display for json_schema::Fragment<N> {
 impl<N: Display> Display for json_schema::NamespaceDefinition<N> {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         for (n, ty) in &self.common_types {
-            writeln!(f, "type {n} = {ty};")?
+            writeln!(f, "{}type {n} = {};", ty.annotations, ty.ty)?
         }
         for (n, ty) in &self.entity_types {
-            writeln!(f, "entity {n}{ty};")?
+            writeln!(f, "{}entity {n}{};", ty.annotations, ty)?
         }
         for (n, a) in &self.actions {
-            writeln!(f, "action \"{}\"{a};", n.escape_debug())?
+            writeln!(f, "{}action \"{}\"{};", a.annotations, n.escape_debug(), a)?
         }
         Ok(())
     }
@@ -80,13 +80,14 @@ impl<N: Display> Display for json_schema::RecordType<N> {
         for (i, (n, ty)) in self.attributes.iter().enumerate() {
             write!(
                 f,
-                "\"{}\"{}: {}",
+                "{}\"{}\"{}: {}",
+                ty.annotations,
                 n.escape_debug(),
                 if ty.required { "" } else { "?" },
                 ty.ty
             )?;
             if i < (self.attributes.len() - 1) {
-                write!(f, ", ")?;
+                writeln!(f, ", ")?;
             }
         }
         write!(f, "}}")?;
@@ -242,6 +243,17 @@ mod tests {
 
     use crate::cedar_schema::parser::parse_cedar_schema_fragment;
 
+    #[track_caller]
+    fn test_round_trip(src: &str) {
+        let (cedar_schema, _) =
+            parse_cedar_schema_fragment(src, Extensions::none()).expect("should parse");
+        let printed_cedar_schema = cedar_schema.to_cedarschema().expect("should convert");
+        let (parsed_cedar_schema, _) =
+            parse_cedar_schema_fragment(&printed_cedar_schema, Extensions::none())
+                .expect("should parse");
+        assert_eq!(cedar_schema, parsed_cedar_schema);
+    }
+
     #[test]
     fn rfc_example() {
         let src = "entity User = {
@@ -250,12 +262,41 @@ mod tests {
           entity Document = {
             owner: User,
           } tags Set<String>;";
-        let (cedar_schema, _) =
-            parse_cedar_schema_fragment(src, Extensions::none()).expect("should parse");
-        let printed_cedar_schema = cedar_schema.to_cedarschema().expect("should convert");
-        let (parsed_cedar_schema, _) =
-            parse_cedar_schema_fragment(&printed_cedar_schema, Extensions::none())
-                .expect("should parse");
-        assert_eq!(cedar_schema, parsed_cedar_schema);
+        test_round_trip(src);
+    }
+
+    #[test]
+    fn annotations() {
+        let src = r#"@doc("this is the namespace")
+namespace TinyTodo {
+    @doc("a common type representing a task")
+    type Task = {
+        @doc("task id")
+        "id": Long,
+        "name": String,
+        "state": String,
+    };
+    @doc("a common type representing a set of tasks")
+    type Tasks = Set<Task>;
+
+    @doc("an entity type representing a list")
+    @docComment("any entity type is a child of type `Application`")
+    entity List in [Application] = {
+        @doc("editors of a list")
+        "editors": Team,
+        "name": String,
+        "owner": User,
+        @doc("readers of a list")
+        "readers": Team,
+        "tasks": Tasks,
+    };
+
+    @doc("actions that a user can operate on a list")
+    action DeleteList, GetList, UpdateList appliesTo {
+        principal: [User],
+        resource: [List]
+    };
+}"#;
+        test_round_trip(src);
     }
 }
diff --git a/cedar-policy-validator/src/cedar_schema/grammar.lalrpop b/cedar-policy-validator/src/cedar_schema/grammar.lalrpop
index 42de91c2..3db5e48a 100644
--- a/cedar-policy-validator/src/cedar_schema/grammar.lalrpop
+++ b/cedar-policy-validator/src/cedar_schema/grammar.lalrpop
@@ -16,9 +16,9 @@
 
 use std::str::FromStr;
 use std::sync::Arc;
-use crate::cedar_schema::err::{RawErrorRecovery, RawUserError, UserError};
+use crate::cedar_schema::err::{RawErrorRecovery, UserError};
 use cedar_policy_core::parser::{Node, Loc, unescape::to_unescaped_string, cst::Ref};
-use cedar_policy_core::ast::Id;
+use cedar_policy_core::ast::{Id, AnyId, Annotations};
 use smol_str::SmolStr;
 use smol_str::ToSmolStr;
 use crate::cedar_schema::ast::{
@@ -35,9 +35,13 @@ use crate::cedar_schema::ast::{
     TypeDecl,
     PrimitiveType,
     QualName,
-    PRAppDecl};
+    PRAppDecl,
+    deduplicate_annotations,
+    Annotated,
+};
 use nonempty::{NonEmpty, nonempty};
 use itertools::Either;
+use std::collections::BTreeMap;
 
 use lalrpop_util::{ParseError, ErrorRecovery};
 
@@ -48,7 +52,7 @@ use lalrpop_util::{ParseError, ErrorRecovery};
 grammar<'err, 's>(errors: &'err mut Vec<RawErrorRecovery<'input>>, src: &'s Arc<str>);
 
 extern {
-    type Error = RawUserError;
+    type Error = UserError;
 }
 
 match {
@@ -82,21 +86,43 @@ match {
 
     // other tokens
     ",", ";", ":", "::", "{", "}", "[", "]",
-    "<", ">", "=", "?",
+    "<", ">", "=", "?", "@", "(", ")",
 
 }
 
+#[inline]
+AnyIdent: Node<AnyId> = {
+    <l:@L> <id:IDENTIFIER> <r:@R>
+        // `IDENTIFIER` should produce a valid `AnyId`
+        => Node::with_source_loc(AnyId::from_str(id).unwrap(), Loc::new(l..r, Arc::clone(src))),
+}
+
+// Annotations := {'@' IDENTIFIER '(' String ')'}
+Annotation: Node<(Node<AnyId>, Option<Node<SmolStr>>)> = {
+    <l:@L> "@" <key:AnyIdent> <value: ("(" <STR> ")")?> <r:@R> => Node::with_source_loc((key, value), Loc::new(l..r, Arc::clone(src)))
+}
+
+Annotated<E>: Annotated<E> = {
+   <annotations: Annotation*> <e:E> =>? {
+        Ok(deduplicate_annotations(e, annotations)?)
+    },
+}
 
 // Schema := {Namespace}
 pub Schema: ASchema = {
     <ns: Namespace*> => ns,
 }
 
+#[inline]
+Namedspace: Namespace = {
+    NAMESPACE <p: Path> "{" <decls: Annotated<Decl>*> "}"
+        => Namespace { name: Some(p), decls},
+}
+
 // Namespace := 'namespace' Path '{' {Decl} '}'
-Namespace: Node<Namespace> = {
-    <l:@L> NAMESPACE <p: Path> "{" <decls: Decl*> "}" <r:@R>
-        => Node::with_source_loc(Namespace { name: Some(Node::with_source_loc(p, Loc::new(l..r, Arc::clone(src)))), decls}, Loc::new(l..r, Arc::clone(src))),
-     <l:@L> <decl: Decl> <r:@R> => Node::with_source_loc(Namespace {name: None, decls: vec![decl]}, Loc::new(l..r, Arc::clone(src))),
+Namespace: Annotated<Namespace> = {
+    <ns: Annotated<Namedspace>> => ns,
+    <decl: Annotated<Decl>> <r:@R> => Annotated {data: Namespace {name: None, decls: vec![decl]}, annotations: Annotations::new()},
 }
 
 // Decl := Entity | Action | TypeDecl
@@ -129,7 +155,7 @@ AppDecls: Node<NonEmpty<Node<AppDecl>>> = {
     <l:@L> <pr: PrincipalOrResource> ":" <ets:EntTypes> ","? <r:@R>
         =>?
                 NonEmpty::collect(ets.into_iter()).ok_or(ParseError::User {
-                    error: Node::with_source_loc(UserError::EmptyList, Loc::new(l..r, Arc::clone(src)))})
+                    error: UserError::EmptyList(Node::with_source_loc((), Loc::new(l..r, Arc::clone(src))))})
                     .map(|ets|
                         Node::with_source_loc(
                             nonempty![Node::with_source_loc(AppDecl::PR(PRAppDecl { kind:pr, entity_tys: ets}), Loc::new(l..r, Arc::clone(src)))],
@@ -137,7 +163,7 @@ AppDecls: Node<NonEmpty<Node<AppDecl>>> = {
     <l:@L> <pr: PrincipalOrResource> ":" <ets:EntTypes> "," <r:@R> <mut ds: AppDecls>
         =>?
                 NonEmpty::collect(ets.into_iter()).ok_or(ParseError::User {
-                    error: Node::with_source_loc(UserError::EmptyList, Loc::new(l..r, Arc::clone(src)))})
+                    error: UserError::EmptyList(Node::with_source_loc((), Loc::new(l..r, Arc::clone(src))))})
                     .map(|ets|
                         {
                             let (mut ds, _) = ds.into_inner();
@@ -183,15 +209,14 @@ pub Type: Node<SType> = {
         => Node::with_source_loc(SType::Record(ds.unwrap_or_default()), Loc::new(l..r, Arc::clone(src))),
 }
 
-// AttrDecls := Name ['?'] ':' Type [',' | ',' AttrDecls]
-AttrDecls: Vec<Node<AttrDecl>> = {
-    <l:@L> <name: Name> <required:"?"?> ":" <ty:Type> ","? <r:@R>
-        => vec![Node::with_source_loc(AttrDecl { name, required: required.is_none(), ty}, Loc::new(l..r, Arc::clone(src)))],
-    <l:@L> <name: Name> <required:"?"?> ":" <ty:Type> "," <r:@R> <mut ds: AttrDecls>
-        => {ds.insert(0, Node::with_source_loc(AttrDecl { name, required: required.is_none(), ty}, Loc::new(l..r, Arc::clone(src)))); ds},
+// AttrDecls := Annotation* Name ['?'] ':' Type [',' | ',' AttrDecls]
+AttrDecls: Vec<Node<Annotated<AttrDecl>>> = {
+    <l:@L> <annotations: Annotation*> <name: Name> <required:"?"?> ":" <ty:Type> ","? <r:@R>
+        =>? Ok(deduplicate_annotations(AttrDecl { name, required: required.is_none(), ty}, annotations).map(|decl| vec![Node::with_source_loc(decl, Loc::new(l..r, Arc::clone(src)))])?),
+    <l:@L> <annotations: Annotation*> <name: Name> <required:"?"?> ":" <ty:Type> "," <r:@R> <mut ds: AttrDecls>
+        =>? {ds.insert(0, deduplicate_annotations(AttrDecl { name, required: required.is_none(), ty}, annotations).map(|decl| Node::with_source_loc(decl, Loc::new(l..r, Arc::clone(src))))?); Ok(ds)},
 }
 
-
 Comma<E>: Vec<E> = {
     <e:E?> => e.into_iter().collect(),
     <mut es:(<E> ",")+> <e:E> => {
@@ -232,14 +257,14 @@ Ident: Node<Id> = {
         => Node::with_source_loc("type".parse().unwrap(), Loc::new(l..r, Arc::clone(src))),
     <l:@L> IN <r:@R>
         =>? Err(ParseError::User {
-            error: Node::with_source_loc(UserError::ReservedIdentifierUsed("in".into()), Loc::new(l..r, Arc::clone(src)))
+            error: UserError::ReservedIdentifierUsed(Node::with_source_loc("in".into(), Loc::new(l..r, Arc::clone(src))))
         }),
     <l:@L> <i:IDENTIFIER> <r:@R>
         =>? Id::from_str(i)
         .map(|id : Id| Node::with_source_loc(id, Loc::new(l..r, Arc::clone(src))))
         .map_err(|err : cedar_policy_core::parser::err::ParseErrors|
             ParseError::User {
-                error: Node::with_source_loc(UserError::ReservedIdentifierUsed(i.to_smolstr()), Loc::new(l..r, Arc::clone(src)))
+                error: UserError::ReservedIdentifierUsed(Node::with_source_loc(i.to_smolstr(), Loc::new(l..r, Arc::clone(src))))
             }
         )
 }
@@ -247,7 +272,7 @@ Ident: Node<Id> = {
 STR: Node<SmolStr> = {
     <l:@L> <s:STRINGLIT> <r:@R>
         =>? to_unescaped_string(&s[1..(s.len() - 1)]).map_or_else(|e| Err(ParseError::User {
-            error: Node::with_source_loc(UserError::StringEscape(e), Loc::new(l..r, Arc::clone(src))),
+            error: UserError::StringEscape(Node::with_source_loc(e, Loc::new(l..r, Arc::clone(src)))),
         }), |v| Ok(Node::with_source_loc(v, Loc::new(l..r, Arc::clone(src))))),
 }
 
diff --git a/cedar-policy-validator/src/cedar_schema/to_json_schema.rs b/cedar-policy-validator/src/cedar_schema/to_json_schema.rs
index 0d7ff29d..f696c2f9 100644
--- a/cedar-policy-validator/src/cedar_schema/to_json_schema.rs
+++ b/cedar-policy-validator/src/cedar_schema/to_json_schema.rs
@@ -19,7 +19,7 @@
 use std::collections::HashMap;
 
 use cedar_policy_core::{
-    ast::{Id, Name, UnreservedId},
+    ast::{Annotations, Id, Name, UnreservedId},
     extensions::Extensions,
     parser::{Loc, Node},
 };
@@ -30,12 +30,16 @@ use std::collections::hash_map::Entry;
 
 use super::{
     ast::{
-        ActionDecl, AppDecl, AttrDecl, Decl, Declaration, EntityDecl, Namespace, PRAppDecl, Path,
-        QualName, Schema, Type, TypeDecl, BUILTIN_TYPES, PR,
+        ActionDecl, Annotated, AppDecl, AttrDecl, Decl, Declaration, EntityDecl, Namespace,
+        PRAppDecl, Path, QualName, Schema, Type, TypeDecl, BUILTIN_TYPES, PR,
     },
     err::{schema_warnings, SchemaWarning, ToJsonSchemaError, ToJsonSchemaErrors},
 };
-use crate::{cedar_schema, json_schema, RawName};
+use crate::{
+    cedar_schema,
+    json_schema::{self, CommonType},
+    RawName,
+};
 
 impl From<cedar_schema::Path> for RawName {
     fn from(p: cedar_schema::Path) -> Self {
@@ -69,14 +73,13 @@ pub fn cedar_schema_to_json_schema(
     // namespaces with matching non-empty names, so that all definitions from
     // that namespace make it into the JSON schema structure under that
     // namespace's key.
-    let (qualified_namespaces, unqualified_namespace) =
-        split_unqualified_namespace(schema.into_iter().map(|n| n.node));
+    let (qualified_namespaces, unqualified_namespace) = split_unqualified_namespace(schema);
     // Create a single iterator for all namespaces
     let all_namespaces = qualified_namespaces
         .chain(unqualified_namespace)
         .collect::<Vec<_>>();
 
-    let names = build_namespace_bindings(all_namespaces.iter())?;
+    let names = build_namespace_bindings(all_namespaces.iter().map(|ns| &ns.data))?;
     let warnings = compute_namespace_warnings(&names, extensions);
     let fragment = collect_all_errors(all_namespaces.into_iter().map(convert_namespace))?.collect();
     Ok((
@@ -117,16 +120,19 @@ pub fn cedar_type_to_json_type(ty: Node<Type>) -> json_schema::Type<RawName> {
 // Split namespaces into two groups: named namespaces and the implicit unqualified namespace
 // The rhs of the tuple will be [`None`] if there are no items in the unqualified namespace.
 fn split_unqualified_namespace(
-    namespaces: impl IntoIterator<Item = Namespace>,
-) -> (impl Iterator<Item = Namespace>, Option<Namespace>) {
+    namespaces: impl IntoIterator<Item = Annotated<Namespace>>,
+) -> (
+    impl Iterator<Item = Annotated<Namespace>>,
+    Option<Annotated<Namespace>>,
+) {
     // First split every namespace into those with explicit names and those without
     let (qualified, unqualified): (Vec<_>, Vec<_>) =
-        namespaces.into_iter().partition(|n| n.name.is_some());
+        namespaces.into_iter().partition(|n| n.data.name.is_some());
 
     // Now combine all the decls in namespaces without names into one unqualified namespace
     let mut unqualified_decls = vec![];
     for mut unqualified_namespace in unqualified.into_iter() {
-        unqualified_decls.append(&mut unqualified_namespace.decls);
+        unqualified_decls.append(&mut unqualified_namespace.data.decls);
     }
 
     if unqualified_decls.is_empty() {
@@ -136,33 +142,42 @@ fn split_unqualified_namespace(
             name: None,
             decls: unqualified_decls,
         };
-        (qualified.into_iter(), Some(unqual))
+        (
+            qualified.into_iter(),
+            Some(Annotated {
+                data: unqual,
+                annotations: Annotations::new(),
+            }),
+        )
     }
 }
 
 /// Converts a CST namespace to a JSON namespace
 fn convert_namespace(
-    namespace: Namespace,
+    namespace: Annotated<Namespace>,
 ) -> Result<(Option<Name>, json_schema::NamespaceDefinition<RawName>), ToJsonSchemaErrors> {
     let ns_name = namespace
+        .data
         .name
         .clone()
         .map(|p| {
-            let internal_name = RawName::from(p.node).qualify_with(None); // namespace names are always written already-fully-qualified in the Cedar schema syntax
+            let internal_name = RawName::from(p.clone()).qualify_with(None); // namespace names are always written already-fully-qualified in the Cedar schema syntax
             Name::try_from(internal_name)
-                .map_err(|e| ToJsonSchemaError::reserved_name(e.name(), p.loc))
+                .map_err(|e| ToJsonSchemaError::reserved_name(e.name(), p.loc().clone()))
         })
         .transpose()?;
     let def = namespace.try_into()?;
     Ok((ns_name, def))
 }
 
-impl TryFrom<Namespace> for json_schema::NamespaceDefinition<RawName> {
+impl TryFrom<Annotated<Namespace>> for json_schema::NamespaceDefinition<RawName> {
     type Error = ToJsonSchemaErrors;
 
-    fn try_from(n: Namespace) -> Result<json_schema::NamespaceDefinition<RawName>, Self::Error> {
+    fn try_from(
+        n: Annotated<Namespace>,
+    ) -> Result<json_schema::NamespaceDefinition<RawName>, Self::Error> {
         // Partition the decls into entities, actions, and common types
-        let (entity_types, action, common_types) = into_partition_decls(n.decls);
+        let (entity_types, action, common_types) = into_partition_decls(n.data.decls);
 
         // Convert entity type decls, collecting all errors
         let entity_types = collect_all_errors(entity_types.into_iter().map(convert_entity_decl))?
@@ -178,12 +193,18 @@ impl TryFrom<Namespace> for json_schema::NamespaceDefinition<RawName> {
         let common_types = common_types
             .into_iter()
             .map(|decl| {
-                let name_loc = decl.name.loc.clone();
-                let id = UnreservedId::try_from(decl.name.node)
+                let name_loc = decl.data.name.loc.clone();
+                let id = UnreservedId::try_from(decl.data.name.node)
                     .map_err(|e| ToJsonSchemaError::reserved_name(e.name(), name_loc.clone()))?;
                 let ctid = json_schema::CommonTypeId::new(id)
                     .map_err(|e| ToJsonSchemaError::reserved_keyword(e.id, name_loc))?;
-                Ok((ctid, cedar_type_to_json_type(decl.def)))
+                Ok((
+                    ctid,
+                    CommonType {
+                        ty: cedar_type_to_json_type(decl.data.def),
+                        annotations: decl.annotations.into(),
+                    },
+                ))
             })
             .collect::<Result<_, ToJsonSchemaError>>()?;
 
@@ -191,19 +212,20 @@ impl TryFrom<Namespace> for json_schema::NamespaceDefinition<RawName> {
             common_types,
             entity_types,
             actions,
+            annotations: n.annotations.into(),
         })
     }
 }
 
 /// Converts action type decls
 fn convert_action_decl(
-    a: ActionDecl,
+    a: Annotated<ActionDecl>,
 ) -> Result<impl Iterator<Item = (SmolStr, json_schema::ActionType<RawName>)>, ToJsonSchemaErrors> {
     let ActionDecl {
         names,
         parents,
         app_decls,
-    } = a;
+    } = a.data;
     // Create the internal type from the 'applies_to' clause and 'member_of'
     let applies_to = app_decls
         .map(|decls| convert_app_decls(&names.first().node, &names.first().loc, decls))
@@ -218,6 +240,7 @@ fn convert_action_decl(
         attributes: None, // Action attributes are currently unsupported in the Cedar schema format
         applies_to: Some(applies_to),
         member_of,
+        annotations: a.annotations.into(),
     };
     // Then map that type across all of the bound names
     Ok(names.into_iter().map(move |name| (name.node, ty.clone())))
@@ -336,21 +359,28 @@ fn convert_id(node: Node<Id>) -> Result<UnreservedId, ToJsonSchemaError> {
 
 /// Convert Entity declarations
 fn convert_entity_decl(
-    e: EntityDecl,
+    e: Annotated<EntityDecl>,
 ) -> Result<
     impl Iterator<Item = (UnreservedId, json_schema::EntityType<RawName>)>,
     ToJsonSchemaErrors,
 > {
     // First build up the defined entity type
     let etype = json_schema::EntityType {
-        member_of_types: e.member_of_types.into_iter().map(RawName::from).collect(),
-        shape: convert_attr_decls(e.attrs),
-        tags: e.tags.map(cedar_type_to_json_type),
+        member_of_types: e
+            .data
+            .member_of_types
+            .into_iter()
+            .map(RawName::from)
+            .collect(),
+        shape: convert_attr_decls(e.data.attrs),
+        tags: e.data.tags.map(cedar_type_to_json_type),
+        annotations: e.annotations.into(),
     };
 
     // Then map over all of the bound names
     collect_all_errors(
-        e.names
+        e.data
+            .names
             .into_iter()
             .map(move |name| -> Result<_, ToJsonSchemaErrors> {
                 Ok((convert_id(name)?, etype.clone()))
@@ -360,7 +390,7 @@ fn convert_entity_decl(
 
 /// Create a [`json_schema::AttributesOrContext`] from a series of `AttrDecl`s
 fn convert_attr_decls(
-    attrs: impl IntoIterator<Item = Node<AttrDecl>>,
+    attrs: impl IntoIterator<Item = Node<Annotated<AttrDecl>>>,
 ) -> json_schema::AttributesOrContext<RawName> {
     json_schema::RecordType {
         attributes: attrs
@@ -374,7 +404,7 @@ fn convert_attr_decls(
 
 /// Create a context decl
 fn convert_context_decl(
-    decl: Either<Path, Vec<Node<AttrDecl>>>,
+    decl: Either<Path, Vec<Node<Annotated<AttrDecl>>>>,
 ) -> json_schema::AttributesOrContext<RawName> {
     json_schema::AttributesOrContext(match decl {
         Either::Left(p) => json_schema::Type::CommonTypeRef {
@@ -393,12 +423,15 @@ fn convert_context_decl(
 }
 
 /// Convert an attribute type from an `AttrDecl`
-fn convert_attr_decl(attr: AttrDecl) -> (SmolStr, json_schema::TypeOfAttribute<RawName>) {
+fn convert_attr_decl(
+    attr: Annotated<AttrDecl>,
+) -> (SmolStr, json_schema::TypeOfAttribute<RawName>) {
     (
-        attr.name.node,
+        attr.data.name.node,
         json_schema::TypeOfAttribute {
-            ty: cedar_type_to_json_type(attr.ty),
-            required: attr.required,
+            ty: cedar_type_to_json_type(attr.data.ty),
+            required: attr.data.required,
+            annotations: attr.annotations.into(),
         },
     )
 }
@@ -444,9 +477,9 @@ impl NamespaceRecord {
             .name
             .clone()
             .map(|n| {
-                let internal_name = RawName::from(n.node).qualify_with(None); // namespace names are already fully-qualified
+                let internal_name = RawName::from(n.clone()).qualify_with(None); // namespace names are already fully-qualified
                 Name::try_from(internal_name)
-                    .map_err(|e| ToJsonSchemaError::reserved_name(e.name(), n.loc))
+                    .map_err(|e| ToJsonSchemaError::reserved_name(e.name(), n.loc().clone()))
             })
             .transpose()?;
         let (entities, actions, types) = partition_decls(&namespace.decls);
@@ -474,7 +507,7 @@ impl NamespaceRecord {
         let record = NamespaceRecord {
             entities,
             common_types,
-            loc: namespace.name.as_ref().map(|n| n.loc.clone()),
+            loc: namespace.name.as_ref().map(|n| n.loc().clone()),
         };
 
         Ok((ns, record))
@@ -594,14 +627,14 @@ fn update_namespace_record(
 }
 
 fn partition_decls(
-    decls: &[Node<Declaration>],
+    decls: &[Annotated<Node<Declaration>>],
 ) -> (Vec<&EntityDecl>, Vec<&ActionDecl>, Vec<&TypeDecl>) {
     let mut entities = vec![];
     let mut actions = vec![];
     let mut types = vec![];
 
     for decl in decls.iter() {
-        match &decl.node {
+        match &decl.data.node {
             Declaration::Entity(e) => entities.push(e),
             Declaration::Action(a) => actions.push(a),
             Declaration::Type(t) => types.push(t),
@@ -612,17 +645,30 @@ fn partition_decls(
 }
 
 fn into_partition_decls(
-    decls: Vec<Node<Declaration>>,
-) -> (Vec<EntityDecl>, Vec<ActionDecl>, Vec<TypeDecl>) {
+    decls: Vec<Annotated<Node<Declaration>>>,
+) -> (
+    Vec<Annotated<EntityDecl>>,
+    Vec<Annotated<ActionDecl>>,
+    Vec<Annotated<TypeDecl>>,
+) {
     let mut entities = vec![];
     let mut actions = vec![];
     let mut types = vec![];
 
     for decl in decls.into_iter() {
-        match decl.node {
-            Declaration::Entity(e) => entities.push(e),
-            Declaration::Action(a) => actions.push(a),
-            Declaration::Type(t) => types.push(t),
+        match decl.data.node {
+            Declaration::Entity(e) => entities.push(Annotated {
+                data: e,
+                annotations: decl.annotations,
+            }),
+            Declaration::Action(a) => actions.push(Annotated {
+                data: a,
+                annotations: decl.annotations,
+            }),
+            Declaration::Type(t) => types.push(Annotated {
+                data: t,
+                annotations: decl.annotations,
+            }),
         }
     }
 
diff --git a/cedar-policy-validator/src/json_schema.rs b/cedar-policy-validator/src/json_schema.rs
index cb4c20e6..f51b2f6a 100644
--- a/cedar-policy-validator/src/json_schema.rs
+++ b/cedar-policy-validator/src/json_schema.rs
@@ -19,6 +19,7 @@
 use cedar_policy_core::{
     ast::{Eid, EntityUID, InternalName, Name, UnreservedId},
     entities::CedarValueJson,
+    est::Annotations,
     extensions::Extensions,
     FromNormalizedStr,
 };
@@ -46,6 +47,19 @@ use crate::{
     AllDefs, CedarSchemaError, CedarSchemaParseError, ConditionalName, RawName, ReferenceType,
 };
 
+/// Represents the definition of a common type in the schema.
+#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)]
+#[serde(bound(deserialize = "N: Deserialize<'de> + From<RawName>"))]
+pub struct CommonType<N> {
+    /// The referred type
+    #[serde(flatten)]
+    pub ty: Type<N>,
+    /// Annotations
+    #[serde(default)]
+    #[serde(skip_serializing_if = "Annotations::is_empty")]
+    pub annotations: Annotations,
+}
+
 /// A [`Fragment`] is split into multiple namespace definitions, and is just a
 /// map from namespace name to namespace definition (i.e., definitions of common
 /// types, entity types, and actions in that namespace).
@@ -293,11 +307,15 @@ pub struct NamespaceDefinition<N> {
     #[serde(default)]
     #[serde(skip_serializing_if = "BTreeMap::is_empty")]
     #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")]
-    pub common_types: BTreeMap<CommonTypeId, Type<N>>,
+    pub common_types: BTreeMap<CommonTypeId, CommonType<N>>,
     #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")]
     pub entity_types: BTreeMap<UnreservedId, EntityType<N>>,
     #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")]
     pub actions: BTreeMap<SmolStr, ActionType<N>>,
+    /// Annotations
+    #[serde(default)]
+    #[serde(skip_serializing_if = "Annotations::is_empty")]
+    pub annotations: Annotations,
 }
 
 impl<N> NamespaceDefinition<N> {
@@ -309,6 +327,7 @@ impl<N> NamespaceDefinition<N> {
             common_types: BTreeMap::new(),
             entity_types: entity_types.into_iter().collect(),
             actions: actions.into_iter().collect(),
+            annotations: Annotations::new(),
         }
     }
 }
@@ -323,7 +342,15 @@ impl NamespaceDefinition<RawName> {
             common_types: self
                 .common_types
                 .into_iter()
-                .map(|(k, v)| (k, v.conditionally_qualify_type_references(ns)))
+                .map(|(k, v)| {
+                    (
+                        k,
+                        CommonType {
+                            ty: v.ty.conditionally_qualify_type_references(ns),
+                            annotations: v.annotations,
+                        },
+                    )
+                })
                 .collect(),
             entity_types: self
                 .entity_types
@@ -335,6 +362,7 @@ impl NamespaceDefinition<RawName> {
                 .into_iter()
                 .map(|(k, v)| (k, v.conditionally_qualify_type_references(ns)))
                 .collect(),
+            annotations: self.annotations,
         }
     }
 }
@@ -354,7 +382,15 @@ impl NamespaceDefinition<ConditionalName> {
             common_types: self
                 .common_types
                 .into_iter()
-                .map(|(k, v)| Ok((k, v.fully_qualify_type_references(all_defs)?)))
+                .map(|(k, v)| {
+                    Ok((
+                        k,
+                        CommonType {
+                            ty: v.ty.fully_qualify_type_references(all_defs)?,
+                            annotations: v.annotations,
+                        },
+                    ))
+                })
                 .collect::<std::result::Result<_, TypeNotDefinedError>>()?,
             entity_types: self
                 .entity_types
@@ -366,6 +402,7 @@ impl NamespaceDefinition<ConditionalName> {
                 .into_iter()
                 .map(|(k, v)| Ok((k, v.fully_qualify_type_references(all_defs)?)))
                 .collect::<Result<_>>()?,
+            annotations: self.annotations,
         })
     }
 }
@@ -398,6 +435,10 @@ pub struct EntityType<N> {
     #[serde(default)]
     #[serde(skip_serializing_if = "Option::is_none")]
     pub tags: Option<Type<N>>,
+    /// Annotations
+    #[serde(default)]
+    #[serde(skip_serializing_if = "Annotations::is_empty")]
+    pub annotations: Annotations,
 }
 
 impl EntityType<RawName> {
@@ -416,6 +457,7 @@ impl EntityType<RawName> {
             tags: self
                 .tags
                 .map(|ty| ty.conditionally_qualify_type_references(ns)),
+            annotations: self.annotations,
         }
     }
 }
@@ -442,6 +484,7 @@ impl EntityType<ConditionalName> {
                 .tags
                 .map(|ty| ty.fully_qualify_type_references(all_defs))
                 .transpose()?,
+            annotations: self.annotations,
         })
     }
 }
@@ -548,6 +591,10 @@ pub struct ActionType<N> {
     #[serde(default)]
     #[serde(skip_serializing_if = "Option::is_none")]
     pub member_of: Option<Vec<ActionEntityUID<N>>>,
+    /// Annotations
+    #[serde(default)]
+    #[serde(skip_serializing_if = "Annotations::is_empty")]
+    pub annotations: Annotations,
 }
 
 impl ActionType<RawName> {
@@ -566,6 +613,7 @@ impl ActionType<RawName> {
                     .map(|aeuid| aeuid.conditionally_qualify_type_references(ns))
                     .collect()
             }),
+            annotations: self.annotations,
         }
     }
 }
@@ -595,6 +643,7 @@ impl ActionType<ConditionalName> {
                         .collect::<std::result::Result<_, ActionNotDefinedError>>()
                 })
                 .transpose()?,
+            annotations: self.annotations,
         })
     }
 }
@@ -1220,15 +1269,26 @@ impl<'de, N: Deserialize<'de> + From<RawName>> TypeVisitor<N> {
                                 attributes: attributes
                                     .0
                                     .into_iter()
-                                    .map(|(k, TypeOfAttribute { ty, required })| {
-                                        (
+                                    .map(
+                                        |(
                                             k,
                                             TypeOfAttribute {
-                                                ty: ty.into_n(),
+                                                ty,
                                                 required,
+                                                annotations,
                                             },
-                                        )
-                                    })
+                                        )| {
+                                            (
+                                                k,
+                                                TypeOfAttribute {
+                                                    ty: ty.into_n(),
+
+                                                    required,
+                                                    annotations,
+                                                },
+                                            )
+                                        },
+                                    )
                                     .collect(),
                                 additional_attributes,
                             })))
@@ -1479,12 +1539,21 @@ impl TypeVariant<RawName> {
                 additional_attributes,
             }) => TypeVariant::Record(RecordType {
                 attributes: BTreeMap::from_iter(attributes.into_iter().map(
-                    |(attr, TypeOfAttribute { ty, required })| {
+                    |(
+                        attr,
+                        TypeOfAttribute {
+                            ty,
+                            required,
+                            annotations,
+                        },
+                    )| {
                         (
                             attr,
                             TypeOfAttribute {
                                 ty: ty.conditionally_qualify_type_references(ns),
+
                                 required,
+                                annotations,
                             },
                         )
                     },
@@ -1552,15 +1621,25 @@ impl TypeVariant<ConditionalName> {
             }) => Ok(TypeVariant::Record(RecordType {
                 attributes: attributes
                     .into_iter()
-                    .map(|(attr, TypeOfAttribute { ty, required })| {
-                        Ok((
+                    .map(
+                        |(
                             attr,
                             TypeOfAttribute {
-                                ty: ty.fully_qualify_type_references(all_defs)?,
+                                ty,
                                 required,
+                                annotations,
                             },
-                        ))
-                    })
+                        )| {
+                            Ok((
+                                attr,
+                                TypeOfAttribute {
+                                    ty: ty.fully_qualify_type_references(all_defs)?,
+                                    required,
+                                    annotations,
+                                },
+                            ))
+                        },
+                    )
                     .collect::<std::result::Result<BTreeMap<_, _>, TypeNotDefinedError>>()?,
                 additional_attributes,
             })),
@@ -1592,7 +1671,12 @@ impl<'a> arbitrary::Arbitrary<'a> for Type<RawName> {
                     let attr_names: BTreeSet<String> = u.arbitrary()?;
                     attr_names
                         .into_iter()
-                        .map(|attr_name| Ok((attr_name.into(), u.arbitrary()?)))
+                        .map(|attr_name| {
+                            Ok((
+                                attr_name.into(),
+                                u.arbitrary::<TypeOfAttribute<RawName>>()?.into(),
+                            ))
+                        })
                         .collect::<arbitrary::Result<_>>()?
                 };
                 TypeVariant::Record(RecordType {
@@ -1645,6 +1729,10 @@ pub struct TypeOfAttribute<N> {
     /// Underlying type of the attribute
     #[serde(flatten)]
     pub ty: Type<N>,
+    /// Annotations
+    #[serde(default)]
+    #[serde(skip_serializing_if = "Annotations::is_empty")]
+    pub annotations: Annotations,
     /// Whether the attribute is required
     #[serde(default = "record_attribute_required_default")]
     #[serde(skip_serializing_if = "is_record_attribute_required_default")]
@@ -1655,7 +1743,9 @@ impl TypeOfAttribute<RawName> {
     fn into_n<N: From<RawName>>(self) -> TypeOfAttribute<N> {
         TypeOfAttribute {
             ty: self.ty.into_n(),
+
             required: self.required,
+            annotations: self.annotations,
         }
     }
 
@@ -1667,6 +1757,7 @@ impl TypeOfAttribute<RawName> {
         TypeOfAttribute {
             ty: self.ty.conditionally_qualify_type_references(ns),
             required: self.required,
+            annotations: self.annotations,
         }
     }
 }
@@ -1685,6 +1776,7 @@ impl TypeOfAttribute<ConditionalName> {
         Ok(TypeOfAttribute {
             ty: self.ty.fully_qualify_type_references(all_defs)?,
             required: self.required,
+            annotations: self.annotations,
         })
     }
 }
@@ -1693,8 +1785,9 @@ impl TypeOfAttribute<ConditionalName> {
 impl<'a> arbitrary::Arbitrary<'a> for TypeOfAttribute<RawName> {
     fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
         Ok(Self {
-            ty: u.arbitrary()?,
+            ty: u.arbitrary::<Type<RawName>>()?,
             required: u.arbitrary()?,
+            annotations: cedar_policy_core::est::Annotations::new(),
         })
     }
 
@@ -2136,7 +2229,7 @@ mod test {
             expect_err(
                 src,
                 &miette::Report::new(e),
-                &ExpectedErrorMessageBuilder::error(r#"unknown field `User`, expected one of `commonTypes`, `entityTypes`, `actions` at line 3 column 35"#)
+                &ExpectedErrorMessageBuilder::error(r#"unknown field `User`, expected one of `commonTypes`, `entityTypes`, `actions`, `annotations` at line 3 column 35"#)
                     .help("JSON formatted schema must specify a namespace. If you want to use the empty namespace, explicitly specify it with `{ \"\": {..} }`")
                     .build());
         });
@@ -2561,11 +2654,11 @@ mod entity_tags {
     fn basic() {
         let json = example_json_schema();
         assert_matches!(Fragment::from_json_value(json), Ok(frag) => {
-            let user = frag.0.get(&None).unwrap().entity_types.get(&"User".parse().unwrap()).unwrap();
+            let user = &frag.0.get(&None).unwrap().entity_types.get(&"User".parse().unwrap()).unwrap();
             assert_matches!(&user.tags, Some(Type::Type(TypeVariant::Set { element })) => {
                 assert_matches!(&**element, Type::Type(TypeVariant::String)); // TODO: why is this `TypeVariant::String` in this case but `EntityOrCommon { "String" }` in all the other cases in this test? Do we accept common types as the element type for sets?
             });
-            let doc = frag.0.get(&None).unwrap().entity_types.get(&"Document".parse().unwrap()).unwrap();
+            let doc = &frag.0.get(&None).unwrap().entity_types.get(&"Document".parse().unwrap()).unwrap();
             assert_matches!(&doc.tags, Some(Type::Type(TypeVariant::Set { element })) => {
                 assert_matches!(&**element, Type::Type(TypeVariant::String)); // TODO: why is this `TypeVariant::String` in this case but `EntityOrCommon { "String" }` in all the other cases in this test? Do we accept common types as the element type for sets?
             });
@@ -2595,7 +2688,7 @@ mod entity_tags {
             "actions": {}
         }});
         assert_matches!(Fragment::from_json_value(json), Ok(frag) => {
-            let user = frag.0.get(&None).unwrap().entity_types.get(&"User".parse().unwrap()).unwrap();
+            let user = &frag.0.get(&None).unwrap().entity_types.get(&"User".parse().unwrap()).unwrap();
             assert_matches!(&user.tags, Some(Type::CommonTypeRef { type_name }) => {
                 assert_eq!(&format!("{type_name}"), "T");
             });
@@ -2622,7 +2715,7 @@ mod entity_tags {
             "actions": {}
         }});
         assert_matches!(Fragment::from_json_value(json), Ok(frag) => {
-            let user = frag.0.get(&None).unwrap().entity_types.get(&"User".parse().unwrap()).unwrap();
+            let user = &frag.0.get(&None).unwrap().entity_types.get(&"User".parse().unwrap()).unwrap();
             assert_matches!(&user.tags, Some(Type::Type(TypeVariant::Entity{ name })) => {
                 assert_eq!(&format!("{name}"), "User");
             });
@@ -2679,7 +2772,9 @@ mod test_json_roundtrip {
                 common_types: BTreeMap::new(),
                 entity_types: BTreeMap::new(),
                 actions: BTreeMap::new(),
-            },
+                annotations: Annotations::new(),
+            }
+            .into(),
         )]));
         roundtrip(fragment);
     }
@@ -2692,7 +2787,9 @@ mod test_json_roundtrip {
                 common_types: BTreeMap::new(),
                 entity_types: BTreeMap::new(),
                 actions: BTreeMap::new(),
-            },
+                annotations: Annotations::new(),
+            }
+            .into(),
         )]));
         roundtrip(fragment);
     }
@@ -2712,7 +2809,9 @@ mod test_json_roundtrip {
                             additional_attributes: false,
                         }))),
                         tags: None,
-                    },
+                        annotations: Annotations::new(),
+                    }
+                    .into(),
                 )]),
                 actions: BTreeMap::from([(
                     "action".into(),
@@ -2729,9 +2828,13 @@ mod test_json_roundtrip {
                             ))),
                         }),
                         member_of: None,
-                    },
+                        annotations: Annotations::new(),
+                    }
+                    .into(),
                 )]),
-            },
+                annotations: Annotations::new(),
+            }
+            .into(),
         )]));
         roundtrip(fragment);
     }
@@ -2754,10 +2857,14 @@ mod test_json_roundtrip {
                                 },
                             ))),
                             tags: None,
-                        },
+                            annotations: Annotations::new(),
+                        }
+                        .into(),
                     )]),
                     actions: BTreeMap::new(),
-                },
+                    annotations: Annotations::new(),
+                }
+                .into(),
             ),
             (
                 None,
@@ -2779,9 +2886,13 @@ mod test_json_roundtrip {
                                 ))),
                             }),
                             member_of: None,
-                        },
+                            annotations: Annotations::new(),
+                        }
+                        .into(),
                     )]),
-                },
+                    annotations: Annotations::new(),
+                }
+                .into(),
             ),
         ]));
         roundtrip(fragment);
@@ -2819,7 +2930,7 @@ mod test_duplicates_error {
             "Foo": {
               "entityTypes" : {
                 "Bar": {},
-                "Bar": {},
+                "Bar": {}
               },
               "actions": {}
             }
@@ -2933,3 +3044,392 @@ mod test_duplicates_error {
         Fragment::from_json_str(src).unwrap();
     }
 }
+
+#[cfg(test)]
+mod annotations {
+    use crate::RawName;
+    use cool_asserts::assert_matches;
+
+    use super::Fragment;
+
+    #[test]
+    fn basic() {
+        let src = serde_json::json!(
+        {
+           "" : {
+            "entityTypes": {},
+            "actions": {},
+            "annotations": {
+                "doc": "this is a doc"
+            }
+           }
+        });
+        let schema: Result<Fragment<RawName>, _> = serde_json::from_value(src);
+        assert_matches!(schema, Ok(_));
+
+        let src = serde_json::json!(
+        {
+           "" : {
+            "entityTypes": {
+                "a": {
+                    "annotations": {
+                        "a": "",
+                        // null is also allowed like ESTs
+                        "d": null,
+                        "b": "c",
+                    },
+                    "shape": {
+                        "type": "Long",
+                    }
+                }
+            },
+            "actions": {},
+            "annotations": {
+                "doc": "this is a doc"
+            }
+           }
+        });
+        let schema: Result<Fragment<RawName>, _> = serde_json::from_value(src);
+        assert_matches!(schema, Ok(_));
+
+        let src = serde_json::json!(
+        {
+           "" : {
+            "entityTypes": {
+                "a": {
+                    "annotations": {
+                        "a": "",
+                        "b": "c",
+                    },
+                    "shape": {
+                        "type": "Long",
+                    }
+                }
+            },
+            "actions": {
+                "a": {
+                    "annotations": {
+                        "doc": "this is a doc"
+                    },
+                    "appliesTo": {
+                        "principalTypes": ["A"],
+                        "resourceTypes": ["B"],
+                    }
+                },
+            },
+            "annotations": {
+                "doc": "this is a doc"
+            }
+           }
+        });
+        let schema: Result<Fragment<RawName>, _> = serde_json::from_value(src);
+        assert_matches!(schema, Ok(_));
+
+        let src = serde_json::json!({
+            "": {
+            "entityTypes": {},
+            "actions": {},
+            "commonTypes": {
+                "Task": {
+                "annotations": {
+                    "doc": "a common type representing a task"
+                },
+                "type": "Record",
+                "attributes": {
+                    "id": {
+                        "type": "Long",
+                        "annotations": {
+                            "doc": "task id"
+                        }
+                    },
+                    "name": {
+                        "type": "String"
+                    },
+                    "state": {
+                        "type": "String"
+                    }
+                }
+        }}}});
+        let schema: Result<Fragment<RawName>, _> = serde_json::from_value(src);
+        assert_matches!(schema, Ok(_));
+
+        let src = serde_json::json!({
+            "": {
+                "entityTypes": {
+                    "User" : {
+                        "shape" : {
+                            "type" : "Record",
+                            "attributes" : {
+                                "name" : {
+                                    "annotations": {
+                                        "a": null,
+                                    },
+                                    "type" : "String"
+                                },
+                                "age" : {
+                                    "type" : "Long"
+                                }
+                            }
+                        }
+                    }
+                },
+                "actions": {},
+                "commonTypes": {}
+        }});
+        let schema: Result<Fragment<RawName>, _> = serde_json::from_value(src);
+        assert_matches!(schema, Ok(_));
+
+        // nested record
+        let src = serde_json::json!({
+            "": {
+                "entityTypes": {
+                    "User" : {
+                        "shape" : {
+                            "type" : "Record",
+                            "attributes" : {
+                                "name" : {
+                                    "annotations": {
+                                        "first_layer": "b"
+                                    },
+                                    "type" : "Record",
+                                    "attributes": {
+                                        "a": {
+                                            "type": "Record",
+                                            "annotations": {
+                                                "second_layer": "d"
+                                            },
+                                            "attributes": {
+                                                "...": {
+                                                    "annotations": {
+                                                        "last_layer": null,
+                                                    },
+                                                    "type": "Long"
+                                                }
+                                            }
+                                        }
+                                    }
+                                },
+                                "age" : {
+                                    "type" : "Long"
+                                }
+                            }
+                        }
+                    }
+                },
+                "actions": {},
+                "commonTypes": {}
+        }});
+        let schema: Result<Fragment<RawName>, _> = serde_json::from_value(src);
+        assert_matches!(schema, Ok(_));
+    }
+
+    #[track_caller]
+    fn test_unknown_fields(src: serde_json::Value, field: &str, expected: &str) {
+        let schema: Result<Fragment<RawName>, _> = serde_json::from_value(src);
+        assert_matches!(schema, Err(errs) => {
+            assert_eq!(errs.to_string(), format!("unknown field {field}, expected one of {expected}"));
+        });
+    }
+
+    const ENTITY_TYPE_EXPECTED_ATTRIBUTES: &str = "`memberOfTypes`, `shape`, `tags`, `annotations`";
+    const NAMESPACE_EXPECTED_ATTRIBUTES: &str =
+        "`commonTypes`, `entityTypes`, `actions`, `annotations`";
+    const ATTRIBUTE_TYPE_EXPECTED_ATTRIBUTES: &str =
+        "`type`, `element`, `attributes`, `additionalAttributes`, `name`";
+    const APPLIES_TO_EXPECTED_ATTRIBUTES: &str = "`resourceTypes`, `principalTypes`, `context`";
+
+    #[test]
+    fn unknown_fields() {
+        let src = serde_json::json!(
+        {
+            "": {
+                "entityTypes": {
+            "UserGroup": {
+                "shape44": {
+                    "type": "Record",
+                    "attributes": {}
+                },
+                "memberOfTypes": [
+                    "UserGroup"
+                ]
+            }},
+            "actions": {},
+        }});
+        test_unknown_fields(src, "`shape44`", ENTITY_TYPE_EXPECTED_ATTRIBUTES);
+
+        let src = serde_json::json!(
+        {
+            "": {
+                "entityTypes": {},
+                "actions": {},
+                "commonTypes": {
+                "C": {
+                    "type": "Set",
+                        "element": {
+                            "annotations": {
+                            "doc": "this is a doc"
+                            },
+                           "type": "Long"
+                        }
+                }
+        }}});
+        test_unknown_fields(src, "`annotations`", ATTRIBUTE_TYPE_EXPECTED_ATTRIBUTES);
+
+        let src = serde_json::json!(
+        {
+            "": {
+                "entityTypes": {},
+                "actions": {},
+                "commonTypes": {
+                "C": {
+                    "type": "Long",
+                    "foo": 1,
+                            "annotations": {
+                            "doc": "this is a doc"
+                            },
+        }}}});
+        test_unknown_fields(src, "`foo`", ATTRIBUTE_TYPE_EXPECTED_ATTRIBUTES);
+
+        let src = serde_json::json!(
+        {
+            "": {
+                "entityTypes": {},
+                "actions": {},
+                "commonTypes": {
+                "C": {
+                    "type": "Record",
+                    "attributes": {
+                        "a": {
+                            "annotations": {
+                            "doc": "this is a doc"
+                            },
+                            "type": "Long",
+                            "foo": 2,
+                            "required": true,
+                        }
+                    },
+        }}}});
+        test_unknown_fields(src, "`foo`", ATTRIBUTE_TYPE_EXPECTED_ATTRIBUTES);
+
+        let src = serde_json::json!(
+        {
+            "": {
+                "entityTypes": {},
+                "actions": {},
+                "commonTypes": {
+                "C": {
+                    "type": "Record",
+                    "attributes": {
+                        "a": {
+                            "annotations": {
+                            "doc": "this is a doc"
+                            },
+                            "type": "Record",
+                            "attributes": {
+                                "b": {
+                                    "annotations": {
+                            "doc": "this is a doc"
+                            },
+                            "type": "Long",
+                            "bar": 3,
+                                },
+                            },
+                            "required": true,
+                        }
+                    },
+        }}}});
+        test_unknown_fields(src, "`bar`", ATTRIBUTE_TYPE_EXPECTED_ATTRIBUTES);
+
+        let src = serde_json::json!(
+        {
+            "": {
+                "entityTypes": {
+            "UserGroup": {
+                "shape": {
+                    "annotations": {
+                        "doc": "this is a doc"
+                    },
+                    "type": "Record",
+                    "attributes": {}
+                },
+                "memberOfTypes": [
+                    "UserGroup"
+                ]
+            }},
+            "actions": {},
+        }});
+        test_unknown_fields(src, "`annotations`", ATTRIBUTE_TYPE_EXPECTED_ATTRIBUTES);
+
+        let src = serde_json::json!(
+        {
+            "": {
+                "entityTypes": {},
+                "actions": {
+                    "a": {
+                        "appliesTo": {
+                            "annotations": {
+                                "doc": "this is a doc"
+                            },
+                            "principalTypes": ["A"],
+                            "resourceTypes": ["B"],
+                        }
+                    },
+                },
+        }});
+        test_unknown_fields(src, "`annotations`", APPLIES_TO_EXPECTED_ATTRIBUTES);
+
+        let src = serde_json::json!(
+        {
+           "" : {
+            "entityTypes": {},
+            "actions": {},
+            "foo": "",
+            "annotations": {
+                "doc": "this is a doc"
+            }
+           }
+        });
+        test_unknown_fields(src, "`foo`", NAMESPACE_EXPECTED_ATTRIBUTES);
+
+        let src = serde_json::json!(
+        {
+           "" : {
+            "entityTypes": {},
+            "actions": {},
+            "commonTypes": {
+                "a": {
+                    "type": "Long",
+                    "annotations": {
+                        "foo": ""
+                    },
+                    "bar": 1
+                }
+            }
+           }
+        });
+        test_unknown_fields(src, "`bar`", ATTRIBUTE_TYPE_EXPECTED_ATTRIBUTES);
+
+        let src = serde_json::json!(
+        {
+           "" : {
+            "entityTypes": {},
+            "actions": {},
+            "commonTypes": {
+                "a": {
+                    "type": "Record",
+                    "annotations": {
+                        "foo": ""
+                    },
+                    "attributes": {
+                        "a": {
+                            "bar": 1,
+                            "type": "Long"
+                        }
+                    }
+                }
+            }
+           }
+        });
+        test_unknown_fields(src, "`bar`", ATTRIBUTE_TYPE_EXPECTED_ATTRIBUTES);
+    }
+}
diff --git a/cedar-policy-validator/src/lib.rs b/cedar-policy-validator/src/lib.rs
index 63abdc23..9a44d451 100644
--- a/cedar-policy-validator/src/lib.rs
+++ b/cedar-policy-validator/src/lib.rs
@@ -59,7 +59,6 @@ pub use str_checks::confusable_string_checks;
 pub mod cedar_schema;
 pub mod typecheck;
 use typecheck::Typechecker;
-
 pub mod types;
 
 /// Used to select how a policy will be validated.
@@ -276,6 +275,7 @@ mod test {
     use super::*;
     use cedar_policy_core::{
         ast::{self, PolicyID},
+        est::Annotations,
         parser::{self, Loc},
     };
 
@@ -293,6 +293,7 @@ mod test {
                         member_of_types: vec![],
                         shape: json_schema::AttributesOrContext::default(),
                         tags: None,
+                        annotations: Annotations::new(),
                     },
                 ),
                 (
@@ -301,6 +302,7 @@ mod test {
                         member_of_types: vec![],
                         shape: json_schema::AttributesOrContext::default(),
                         tags: None,
+                        annotations: Annotations::new(),
                     },
                 ),
             ],
@@ -314,6 +316,7 @@ mod test {
                     }),
                     member_of: None,
                     attributes: None,
+                    annotations: Annotations::new(),
                 },
             )],
         );
diff --git a/cedar-policy-validator/src/rbac.rs b/cedar-policy-validator/src/rbac.rs
index b27ad86f..04ca0815 100644
--- a/cedar-policy-validator/src/rbac.rs
+++ b/cedar-policy-validator/src/rbac.rs
@@ -397,10 +397,8 @@ mod test {
     use std::collections::{HashMap, HashSet};
 
     use cedar_policy_core::{
-        ast::{
-            Annotations, Effect, Eid, EntityUID, Expr, PolicyID, PrincipalConstraint,
-            ResourceConstraint,
-        },
+        ast::{Effect, Eid, EntityUID, Expr, PolicyID, PrincipalConstraint, ResourceConstraint},
+        est::Annotations,
         parser::{parse_policy, parse_policy_or_template},
         test_utils::{expect_err, ExpectedErrorMessageBuilder},
     };
@@ -488,6 +486,7 @@ mod test {
                     member_of_types: vec![],
                     shape: json_schema::AttributesOrContext::default(),
                     tags: None,
+                    annotations: Annotations::new(),
                 },
             )],
             [],
@@ -496,7 +495,7 @@ mod test {
         let policy = Template::new(
             PolicyID::from_string("policy0"),
             None,
-            Annotations::new(),
+            ast::Annotations::new(),
             Effect::Permit,
             PrincipalConstraint::any(),
             ActionConstraint::any(),
@@ -523,6 +522,7 @@ mod test {
                     member_of_types: vec![],
                     shape: json_schema::AttributesOrContext::default(),
                     tags: None,
+                    annotations: Annotations::new(),
                 },
             )],
             [],
@@ -575,6 +575,7 @@ mod test {
                     applies_to: None,
                     member_of: None,
                     attributes: None,
+                    annotations: Annotations::new(),
                 },
             )],
         );
@@ -584,7 +585,7 @@ mod test {
         let policy = Template::new(
             PolicyID::from_string("policy0"),
             None,
-            Annotations::new(),
+            ast::Annotations::new(),
             Effect::Permit,
             PrincipalConstraint::any(),
             ActionConstraint::is_eq(entity),
@@ -609,6 +610,7 @@ mod test {
                     member_of_types: vec![],
                     shape: json_schema::AttributesOrContext::default(),
                     tags: None,
+                    annotations: Annotations::new(),
                 },
             )],
             [],
@@ -634,6 +636,7 @@ mod test {
                     member_of_types: vec![],
                     shape: json_schema::AttributesOrContext::default(),
                     tags: None,
+                    annotations: Annotations::new(),
                 },
             )],
             [],
@@ -659,6 +662,7 @@ mod test {
                     member_of_types: vec![],
                     shape: json_schema::AttributesOrContext::default(),
                     tags: None,
+                    annotations: Annotations::new(),
                 },
             )],
             [],
@@ -704,6 +708,7 @@ mod test {
                     applies_to: None,
                     member_of: None,
                     attributes: None,
+                    annotations: Annotations::new(),
                 },
             )],
         );
@@ -736,6 +741,7 @@ mod test {
                     applies_to: None,
                     member_of: None,
                     attributes: None,
+                    annotations: Annotations::new(),
                 },
             )],
         );
@@ -810,7 +816,7 @@ mod test {
         let policy = Template::new(
             PolicyID::from_string("policy0"),
             None,
-            Annotations::new(),
+            ast::Annotations::new(),
             Effect::Permit,
             PrincipalConstraint::any(),
             ActionConstraint::is_eq(entity),
@@ -871,7 +877,7 @@ mod test {
         let policy = Template::new(
             PolicyID::from_string("policy0"),
             None,
-            Annotations::new(),
+            ast::Annotations::new(),
             Effect::Permit,
             PrincipalConstraint::is_eq(Arc::new(EntityUID::from_components(
                 entity_type,
@@ -935,6 +941,7 @@ mod test {
                     applies_to: None,
                     member_of: None,
                     attributes: None,
+                    annotations: Annotations::new(),
                 },
             )],
         );
@@ -962,6 +969,7 @@ mod test {
                     applies_to: None,
                     member_of: None,
                     attributes: None,
+                    annotations: Annotations::new(),
                 },
             )],
         );
@@ -989,6 +997,7 @@ mod test {
                     applies_to: None,
                     member_of: None,
                     attributes: None,
+                    annotations: Annotations::new(),
                 },
             )],
         );
@@ -1015,6 +1024,7 @@ mod test {
                     member_of_types: vec![],
                     shape: json_schema::AttributesOrContext::default(),
                     tags: None,
+                    annotations: Annotations::new(),
                 },
             )],
             [],
@@ -1049,6 +1059,7 @@ mod test {
                         member_of_types: vec![],
                         shape: json_schema::AttributesOrContext::default(),
                         tags: None,
+                        annotations: Annotations::new(),
                     },
                 ),
                 (
@@ -1057,6 +1068,7 @@ mod test {
                         member_of_types: vec![],
                         shape: json_schema::AttributesOrContext::default(),
                         tags: None,
+                        annotations: Annotations::new(),
                     },
                 ),
             ],
@@ -1070,6 +1082,7 @@ mod test {
                     }),
                     member_of: Some(vec![]),
                     attributes: None,
+                    annotations: Annotations::new(),
                 },
             )],
         )
@@ -1136,7 +1149,7 @@ mod test {
         let policy = Template::new(
             PolicyID::from_string("policy0"),
             None,
-            Annotations::new(),
+            ast::Annotations::new(),
             Effect::Permit,
             PrincipalConstraint::is_eq(Arc::new(principal)),
             ActionConstraint::is_eq(action),
@@ -1440,6 +1453,7 @@ mod test {
                         member_of_types: vec![],
                         shape: json_schema::AttributesOrContext::default(),
                         tags: None,
+                        annotations: Annotations::new(),
                     },
                 ),
                 (
@@ -1448,6 +1462,7 @@ mod test {
                         member_of_types: vec![resource_parent_type.parse().unwrap()],
                         shape: json_schema::AttributesOrContext::default(),
                         tags: None,
+                        annotations: Annotations::new(),
                     },
                 ),
                 (
@@ -1456,6 +1471,7 @@ mod test {
                         member_of_types: vec![resource_grandparent_type.parse().unwrap()],
                         shape: json_schema::AttributesOrContext::default(),
                         tags: None,
+                        annotations: Annotations::new(),
                     },
                 ),
                 (
@@ -1464,6 +1480,7 @@ mod test {
                         member_of_types: vec![],
                         shape: json_schema::AttributesOrContext::default(),
                         tags: None,
+                        annotations: Annotations::new(),
                     },
                 ),
             ],
@@ -1481,6 +1498,7 @@ mod test {
                             action_parent_name.into(),
                         )]),
                         attributes: None,
+                        annotations: Annotations::new(),
                     },
                 ),
                 (
@@ -1492,6 +1510,7 @@ mod test {
                             action_grandparent_name.into(),
                         )]),
                         attributes: None,
+                        annotations: Annotations::new(),
                     },
                 ),
                 (
@@ -1500,6 +1519,7 @@ mod test {
                         applies_to: None,
                         member_of: Some(vec![]),
                         attributes: None,
+                        annotations: Annotations::new(),
                     },
                 ),
             ],
@@ -1509,7 +1529,7 @@ mod test {
         let policy = Template::new(
             PolicyID::from_string("policy0"),
             None,
-            Annotations::new(),
+            ast::Annotations::new(),
             Effect::Permit,
             PrincipalConstraint::any(),
             ActionConstraint::is_in([action_grandparent_euid]),
diff --git a/cedar-policy-validator/src/schema.rs b/cedar-policy-validator/src/schema.rs
index d93b012f..c3241fc8 100644
--- a/cedar-policy-validator/src/schema.rs
+++ b/cedar-policy-validator/src/schema.rs
@@ -1361,6 +1361,7 @@ impl<'a> CommonTypeResolver<'a> {
                                     json_schema::TypeOfAttribute {
                                         required: attr_ty.required,
                                         ty: Self::resolve_type(resolve_table, attr_ty.ty)?,
+                                        annotations: attr_ty.annotations,
                                     },
                                 ))
                             })
diff --git a/cedar-policy-validator/src/schema/namespace_def.rs b/cedar-policy-validator/src/schema/namespace_def.rs
index 357008e1..5c98bf68 100644
--- a/cedar-policy-validator/src/schema/namespace_def.rs
+++ b/cedar-policy-validator/src/schema/namespace_def.rs
@@ -132,8 +132,13 @@ impl ValidatorNamespaceDef<ConditionalName, ConditionalName> {
 
         // Convert the common types, actions and entity types from the schema
         // file into the representation used by the validator.
-        let common_types =
-            CommonTypeDefs::from_raw_common_types(namespace_def.common_types, namespace.as_ref())?;
+        let common_types = CommonTypeDefs::from_raw_common_types(
+            namespace_def
+                .common_types
+                .into_iter()
+                .map(|(key, value)| (key, value.ty)),
+            namespace.as_ref(),
+        )?;
         let actions =
             ActionsDef::from_raw_actions(namespace_def.actions, namespace.as_ref(), extensions)?;
         let entity_types =
@@ -276,10 +281,10 @@ impl CommonTypeDefs<ConditionalName> {
     /// structures used by the schema format to those used internally by the
     /// validator.
     pub(crate) fn from_raw_common_types(
-        schema_file_type_def: BTreeMap<CommonTypeId, json_schema::Type<RawName>>,
+        schema_file_type_def: impl IntoIterator<Item = (CommonTypeId, json_schema::Type<RawName>)>,
         schema_namespace: Option<&InternalName>,
     ) -> crate::err::Result<Self> {
-        let mut defs = HashMap::with_capacity(schema_file_type_def.len());
+        let mut defs = HashMap::new();
         for (id, schema_ty) in schema_file_type_def {
             let name = RawName::new_from_unreserved(id.into()).qualify_with(schema_namespace); // the declaration name is always (unconditionally) prefixed by the current/active namespace
             match defs.entry(name) {
@@ -390,10 +395,10 @@ impl EntityTypesDef<ConditionalName> {
     /// structures used by the schema format to those used internally by the
     /// validator.
     pub(crate) fn from_raw_entity_types(
-        schema_files_types: BTreeMap<UnreservedId, json_schema::EntityType<RawName>>,
+        schema_files_types: impl IntoIterator<Item = (UnreservedId, json_schema::EntityType<RawName>)>,
         schema_namespace: Option<&InternalName>,
     ) -> crate::err::Result<Self> {
-        let mut defs: HashMap<EntityType, _> = HashMap::with_capacity(schema_files_types.len());
+        let mut defs: HashMap<EntityType, _> = HashMap::new();
         for (id, entity_type) in schema_files_types {
             let ety = internal_name_to_entity_type(
                 RawName::new_from_unreserved(id).qualify_with(schema_namespace), // the declaration name is always (unconditionally) prefixed by the current/active namespace
@@ -580,11 +585,11 @@ impl ActionsDef<ConditionalName, ConditionalName> {
     /// Construct an [`ActionsDef<ConditionalName>`] by converting the structures used by the
     /// schema format to those used internally by the validator.
     pub(crate) fn from_raw_actions(
-        schema_file_actions: BTreeMap<SmolStr, json_schema::ActionType<RawName>>,
+        schema_file_actions: impl IntoIterator<Item = (SmolStr, json_schema::ActionType<RawName>)>,
         schema_namespace: Option<&InternalName>,
         extensions: &Extensions<'_>,
     ) -> crate::err::Result<Self> {
-        let mut actions = HashMap::with_capacity(schema_file_actions.len());
+        let mut actions = HashMap::new();
         for (action_id_str, action_type) in schema_file_actions {
             let action_uid = json_schema::ActionEntityUID::default_type(action_id_str.clone())
                 .qualify_with(schema_namespace); // the declaration name is always (unconditionally) prefixed by the current/active namespace
@@ -1023,7 +1028,7 @@ pub(crate) fn try_record_type_into_validator_type(
         Err(UnsupportedFeatureError(UnsupportedFeature::OpenRecordsAndEntities).into())
     } else {
         Ok(
-            parse_record_attributes(rty.attributes, extensions)?.map(move |attrs| {
+            parse_record_attributes(rty.attributes.into_iter(), extensions)?.map(move |attrs| {
                 Type::record_with_attributes(
                     attrs,
                     if rty.additional_attributes {
diff --git a/cedar-policy/CHANGELOG.md b/cedar-policy/CHANGELOG.md
index 919be6a1..9a0c76a9 100644
--- a/cedar-policy/CHANGELOG.md
+++ b/cedar-policy/CHANGELOG.md
@@ -15,6 +15,7 @@ Cedar Language Version: TBD
 
 ### Added
 
+- Implemented [RFC 48 (schema annotations)](https://github.com/cedar-policy/rfcs/blob/main/text/0048-schema-annotations.md)  (#1316)
 - New `.isEmpty()` operator on sets (#1358, resolving #1356)
 - 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`.
diff --git a/cedar-policy/Cargo.toml b/cedar-policy/Cargo.toml
index 1a89f778..5558a8b6 100644
--- a/cedar-policy/Cargo.toml
+++ b/cedar-policy/Cargo.toml
@@ -31,7 +31,7 @@ prost = { version = "0.13", optional = true }
 serde-wasm-bindgen = { version = "0.6", optional = true }
 tsify = { version = "0.4.5", optional = true }
 wasm-bindgen = { version = "0.2.97", optional = true }
-semver = "1.0.23"
+semver = "1.0.24"
 lazy_static = "1.5.0"
 
 [features]
diff --git a/cedar-policy/src/ffi/utils.rs b/cedar-policy/src/ffi/utils.rs
index 49dfe10d..60e15387 100644
--- a/cedar-policy/src/ffi/utils.rs
+++ b/cedar-policy/src/ffi/utils.rs
@@ -1196,7 +1196,7 @@ mod test {
             &ExpectedErrorMessageBuilder::error("failed to parse schema from string")
                 .exactly_one_underline_with_label(
                     "permit",
-                    "expected `action`, `entity`, `namespace`, or `type`",
+                    "expected `@`, `action`, `entity`, `namespace`, or `type`",
                 )
                 .source("error parsing schema: unexpected token `permit`")
                 .build(),
diff --git a/cedar-wasm/build-wasm.sh b/cedar-wasm/build-wasm.sh
index 4b2a6d07..dde96709 100755
--- a/cedar-wasm/build-wasm.sh
+++ b/cedar-wasm/build-wasm.sh
@@ -96,6 +96,7 @@ process_types_file() {
 
     echo "type SmolStr = string;" >> "$types_file"
     echo "export type TypeOfAttribute<N> = Type<N> & { required?: boolean };" >> "$types_file"
+    echo "export type CommonType<N> = Type<N> & { annotations?: Annotations };" >> "$types_file"
 }
 
 check_types_file() {


Test patch:
diff --git a/cedar-policy-validator/src/cedar_schema/test.rs b/cedar-policy-validator/src/cedar_schema/test.rs
index 158a602b..bf72a6ab 100644
--- a/cedar-policy-validator/src/cedar_schema/test.rs
+++ b/cedar-policy-validator/src/cedar_schema/test.rs
@@ -23,8 +23,8 @@ mod demo_tests {
         iter::{empty, once},
     };
 
-    use cedar_policy_core::extensions::Extensions;
     use cedar_policy_core::test_utils::{expect_err, ExpectedErrorMessageBuilder};
+    use cedar_policy_core::{est::Annotations, extensions::Extensions};
     use cool_asserts::assert_matches;
     use smol_str::ToSmolStr;
 
@@ -213,7 +213,7 @@ mod demo_tests {
             json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available()).unwrap();
         let unqual = schema.0.get(&None).unwrap();
         let foo = unqual.actions.get("Foo").unwrap();
-        assert_matches!(foo,
+        assert_matches!(&foo,
                 json_schema::ActionType {
                     applies_to : Some(json_schema::ApplySpec {
                         resource_types,
@@ -336,6 +336,7 @@ mod demo_tests {
             attributes: None,
             applies_to: None,
             member_of: None,
+            annotations: Annotations::new(),
         };
         let namespace =
             json_schema::NamespaceDefinition::new(empty(), once(("foo".to_smolstr(), action)));
@@ -437,6 +438,7 @@ namespace Baz {action "Foo" appliesTo {
                     member_of_types: vec![],
                     shape: json_schema::AttributesOrContext::default(),
                     tags: None,
+                    annotations: Annotations::new(),
                 },
             )]),
             actions: BTreeMap::from([(
@@ -449,8 +451,10 @@ namespace Baz {action "Foo" appliesTo {
                         context: json_schema::AttributesOrContext::default(),
                     }),
                     member_of: None,
+                    annotations: Annotations::new(),
                 },
             )]),
+            annotations: Annotations::new(),
         };
         let fragment = json_schema::Fragment(BTreeMap::from([(None, namespace)]));
         let src = fragment.to_cedarschema().unwrap();
@@ -544,7 +548,7 @@ namespace Baz {action "Foo" appliesTo {
             &vec!["UserGroup".parse().unwrap(), "Team".parse().unwrap()]
         );
         // UserGroup
-        let usergroup = github
+        let usergroup = &github
             .entity_types
             .get(&"UserGroup".parse().unwrap())
             .expect("No `UserGroup`");
@@ -733,7 +737,7 @@ namespace Baz {action "Foo" appliesTo {
                 }),
             );
             assert_has_type(
-                attributes.get("viewACL").unwrap(),
+                &attributes.get("viewACL").unwrap(),
                 json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon {
                     type_name: "DocumentShare".parse().unwrap(),
                 }),
@@ -878,7 +882,7 @@ namespace Baz {action "Foo" appliesTo {
             json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available()).unwrap();
         assert_eq!(warnings.collect::<Vec<_>>(), vec![]);
         let service = fragment.0.get(&Some("Service".parse().unwrap())).unwrap();
-        let resource = service
+        let resource = &service
             .entity_types
             .get(&"Resource".parse().unwrap())
             .unwrap();
@@ -886,8 +890,8 @@ namespace Baz {action "Foo" appliesTo {
             attributes,
             additional_attributes: false,
         }))) => {
-            assert_matches!(attributes.get("tag"), Some(json_schema::TypeOfAttribute { ty, required: true }) => {
-                assert_matches!(ty, json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name }) => {
+            assert_matches!(attributes.get("tag"), Some(json_schema::TypeOfAttribute { ty, required: true, .. }) => {
+                assert_matches!(&ty, json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name }) => {
                     assert_eq!(type_name, &"AWS::Tag".parse().unwrap());
                 });
             });
@@ -917,7 +921,7 @@ namespace Baz {action "Foo" appliesTo {
         assert_labeled_span("type t =", "expected `{`, identifier, or `Set`");
         assert_labeled_span(
             "entity User {",
-            "expected `}`, identifier, or string literal",
+            "expected `@`, `}`, identifier, or string literal",
         );
         assert_labeled_span("entity User { name:", "expected `{`, identifier, or `Set`");
     }
@@ -1417,18 +1421,18 @@ mod translator_tests {
         let (frag, _) =
             json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available()).unwrap();
         let demo = frag.0.get(&Some("Demo".parse().unwrap())).unwrap();
-        let user = demo.entity_types.get(&"User".parse().unwrap()).unwrap();
+        let user = &demo.entity_types.get(&"User".parse().unwrap()).unwrap();
         assert_matches!(&user.shape, json_schema::AttributesOrContext(json_schema::Type::Type(json_schema::TypeVariant::Record(json_schema::RecordType {
             attributes,
             additional_attributes: false,
         }))) => {
-            assert_matches!(attributes.get("name"), Some(json_schema::TypeOfAttribute { ty, required: true }) => {
+            assert_matches!(attributes.get("name"), Some(json_schema::TypeOfAttribute { ty, required: true, .. }) => {
                 let expected = json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon {
                     type_name: "id".parse().unwrap(),
                 });
                 assert_eq!(ty, &expected);
             });
-            assert_matches!(attributes.get("email"), Some(json_schema::TypeOfAttribute { ty, required: true }) => {
+            assert_matches!(attributes.get("email"), Some(json_schema::TypeOfAttribute { ty, required: true, .. }) => {
                 let expected = json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon {
                     type_name: "email_address".parse().unwrap(),
                 });
@@ -1984,6 +1988,266 @@ mod translator_tests {
         .map(|_| ());
         assert_matches!(schema, Err(errs) if matches!(errs.iter().next().unwrap(), ToJsonSchemaError::ReservedSchemaKeyword(_)));
     }
+
+    #[track_caller]
+    fn test_translation(src: &str, json_value: serde_json::Value) {
+        let (schema, _) = cedar_schema_to_json_schema(
+            parse_schema(src).expect("should parse Cedar schema"),
+            Extensions::none(),
+        )
+        .expect("should translate to JSON schema");
+        assert_eq!(serde_json::to_value(schema).unwrap(), json_value);
+    }
+    #[test]
+    fn annotations() {
+        // namespace annotations
+        test_translation(
+            r#"
+            @a1
+            @a2("")
+            @a3("foo")
+            namespace N {
+              entity E;
+            }
+            "#,
+            serde_json::json!({
+                "N": {
+                    "entityTypes": {
+                        "E": {}
+                    },
+                    "actions": {},
+                    "annotations": {
+                        "a1": "",
+                        "a2": "",
+                        "a3": "foo",
+                    }
+                }
+            }),
+        );
+
+        // common type annotations
+        test_translation(
+            r#"
+            @comment("A->B")
+            type A = B;
+            @comment("B->A")
+            type B = A;
+            "#,
+            serde_json::json!({
+                "": {
+                    "entityTypes": {},
+                    "actions": {},
+                    "commonTypes": {
+                       "A": {
+                        "type": "EntityOrCommon",
+                        "name": "B",
+                        "annotations": {
+                            "comment": "A->B",
+                        }
+                       },
+                       "B": {
+                        "type": "EntityOrCommon",
+                        "name": "A",
+                        "annotations": {
+                            "comment": "B->A",
+                        }
+                       }
+                    }
+                }
+            }),
+        );
+
+        // entity type annotations
+        test_translation(
+            r#"
+            @a1
+            @a2("")
+            @a3("foo")
+            namespace N {
+              @ae1("🌕")
+              @ae2("moon")
+              entity Moon;
+            }
+            @ae("🌎")
+            entity Earth;
+            "#,
+            serde_json::json!({
+                "": {
+                    "entityTypes": {
+                        "Earth": {
+                            "annotations": {
+                                "ae": "🌎",
+                            }
+                        }
+                    },
+                    "actions": {},
+                },
+                "N": {
+                    "entityTypes": {
+                        "Moon": {
+                            "annotations": {
+                                "ae1": "🌕",
+                                "ae2": "moon",
+                            }
+                        }
+                    },
+                    "actions": {},
+                    "annotations": {
+                        "a1": "",
+                        "a2": "",
+                        "a3": "foo",
+                    }
+                }
+            }),
+        );
+        test_translation(
+            r#"
+            @ae("🍎🍏")
+            entity Apple1, Apple2;
+            "#,
+            serde_json::json!({
+                "": {
+                    "entityTypes": {
+                        "Apple1": {
+                            "annotations": {
+                                "ae": "🍎🍏",
+                            }
+                        },
+                        "Apple2": {
+                            "annotations": {
+                                "ae": "🍎🍏",
+                            }
+                        }
+                    },
+                    "actions": {},
+                }
+            }),
+        );
+
+        // action annotations
+        test_translation(
+            r#"
+            @a1
+            @a2("")
+            @a3("foo")
+            namespace N {
+              @ae1("🌕")
+              @ae2("moon")
+              entity Moon;
+            }
+            @a("🌌")
+            action "🚀","🛸" appliesTo {
+                principal: [Astronaut, ET],
+                resource: Earth,
+            };
+            "#,
+            serde_json::json!({
+                "": {
+                    "entityTypes": {},
+                    "actions": {
+                        "🚀": {
+                            "annotations": {
+                                "a": "🌌",
+                            },
+                            "appliesTo": {
+                                "principalTypes": ["Astronaut", "ET"],
+                                "resourceTypes": ["Earth"],
+                            }
+                        },
+                        "🛸": {
+                            "annotations": {
+                                "a": "🌌",
+                            },
+                            "appliesTo": {
+                                "principalTypes": ["Astronaut", "ET"],
+                                "resourceTypes": ["Earth"],
+                            }
+                        }
+                    },
+                },
+                "N": {
+                    "entityTypes": {
+                        "Moon": {
+                            "annotations": {
+                                "ae1": "🌕",
+                                "ae2": "moon",
+                            }
+                        }
+                    },
+                    "actions": {},
+                    "annotations": {
+                        "a1": "",
+                        "a2": "",
+                        "a3": "foo",
+                    }
+                }
+            }),
+        );
+
+        // attribute annotations
+        test_translation(
+            r#"
+            type Stars = {
+                @a1
+                "🌕": Long,
+                @a2
+                "🌎": Long,
+                @a3
+                "🛰️": {
+                  @a4("Rocket")
+                  "🚀": Long,
+                  "🌌": Long,
+                }
+            };
+            "#,
+            serde_json::json!({
+                "": {
+                    "entityTypes": {},
+                    "actions": {},
+                    "commonTypes": {
+                        "Stars": {
+                            "type": "Record",
+                            "attributes": {
+                                "🌕": {
+                                    "type": "EntityOrCommon",
+                                    "name": "Long",
+                                    "annotations": {
+                                        "a1": "",
+                                    }
+                                },
+                                "🌎": {
+                                    "type": "EntityOrCommon",
+                                    "name": "Long",
+                                    "annotations": {
+                                        "a2": "",
+                                    }
+                                },
+                                "🛰️": {
+                                    "type": "Record",
+                                    "annotations": {
+                                        "a3": "",
+                                    },
+                                    "attributes": {
+                                        "🚀": {
+                                            "type": "EntityOrCommon",
+                                            "name": "Long",
+                                            "annotations": {
+                                                "a4": "Rocket",
+                                            }
+                                        },
+                                        "🌌": {
+                                            "type": "EntityOrCommon",
+                                            "name": "Long",
+                                        }
+                                    }
+                                },
+                            },
+                       },
+                    }
+                }
+            }),
+        );
+    }
 }
 
 #[cfg(test)]
@@ -2340,7 +2604,7 @@ mod entity_tags {
             assert!(warnings.is_empty());
             let entity_type = frag.0.get(&None).unwrap().entity_types.get(&"E".parse().unwrap()).unwrap();
             assert_matches!(&entity_type.tags, Some(json_schema::Type::Type(json_schema::TypeVariant::Record(rty))) => {
-                assert_matches!(rty.attributes.get("foo"), Some(json_schema::TypeOfAttribute { ty, required }) => {
+                assert_matches!(rty.attributes.get("foo"), Some(json_schema::TypeOfAttribute { ty, required, .. }) => {
                     assert_matches!(ty, json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name }) => {
                         assert_eq!(&format!("{type_name}"), "String");
                     });
@@ -2368,3 +2632,135 @@ mod entity_tags {
         });
     }
 }
+
+// RFC 48 test cases
+#[cfg(test)]
+mod annotations {
+    use cool_asserts::assert_matches;
+
+    use crate::cedar_schema::parser::parse_schema;
+
+    #[test]
+    fn no_keys() {
+        assert_matches!(
+            parse_schema(
+                r#"
+        @doc("This entity defines our central user type")
+entity User {
+    @manager
+    manager : User,
+    @team
+    team : String
+};
+        "#
+            ),
+            Ok(_)
+        );
+    }
+
+    #[test]
+    fn duplicate_keys() {
+        assert_matches!(
+            parse_schema(
+                r#"
+        @doc("This entity defines our central user type")
+        @doc
+entity User {
+    @manager
+    manager : User,
+    @team
+    team : String
+};
+        "#
+            ),
+            Err(errs) => {
+                assert_eq!(errs.0.as_ref().first().to_string(), "duplicate annotations: `doc`");
+            }
+        );
+    }
+
+    #[test]
+    fn rfc_examples() {
+        // basic
+        assert_matches!(
+            parse_schema(
+                r#"
+        @doc("This entity defines our central user type")
+entity User { 
+    manager : User,
+    team : String
+};
+        "#
+            ),
+            Ok(_)
+        );
+        // basic + namespace
+        assert_matches!(
+            parse_schema(
+                r#"
+        @doc("this is namespace foo")
+        namespace foo {
+@doc("This entity defines our central user type")
+entity User { 
+    manager : User,
+    team : String
+};
+    }
+        "#
+            ),
+            Ok(_)
+        );
+        // entity attribute annotation
+        assert_matches!(
+            parse_schema(
+                r#"
+@doc("This entity defines our central user type")
+entity User { 
+    manager : User,
+
+    @doc("Which team user belongs to")    
+    @docLink("https://schemaDocs.example.com/User/team")
+    team : String
+};
+"#
+            ),
+            Ok(_)
+        );
+        // full example
+        assert_matches!(
+            parse_schema(
+                r#"
+        @doc("this is the namespace")
+namespace TinyTodo {
+    @doc("a common type representing a task")
+    type Task = {
+        "id": Long,
+        "name": String,
+        "state": String,
+    };
+    @doc("a common type representing a set of tasks")
+    type Tasks = Set<Task>;
+
+    @doc1("an entity type representing a list")
+    @doc2("any entity type is a child of type `Application`")
+    entity List in [Application] = {
+        @doc("editors of a list")
+        "editors": Team,
+        "name": String,
+        "owner": User,
+        @doc("readers of a list")
+        "readers": Team,
+        "tasks": Tasks,
+    };
+
+    @doc("actions that a user can operate on a list")
+    action DeleteList, GetList, UpdateList appliesTo {
+        principal: [User],
+        resource: [List]
+    };
+}"#
+            ),
+            Ok(_)
+        );
+    }
+}
diff --git a/cedar-policy-validator/src/typecheck/test/expr.rs b/cedar-policy-validator/src/typecheck/test/expr.rs
index 73a88e87..5a80d8a0 100644
--- a/cedar-policy-validator/src/typecheck/test/expr.rs
+++ b/cedar-policy-validator/src/typecheck/test/expr.rs
@@ -22,6 +22,7 @@ use std::{str::FromStr, vec};
 
 use cedar_policy_core::{
     ast::{BinaryOp, EntityUID, Expr, Name, Pattern, PatternElem, SlotId, Value, Var},
+    est::Annotations,
     extensions::Extensions,
 };
 use itertools::Itertools;
@@ -67,6 +68,7 @@ fn slot_in_typechecks() {
         member_of_types: vec![],
         shape: json_schema::AttributesOrContext::default(),
         tags: None,
+        annotations: Annotations::new(),
     };
     let schema = json_schema::NamespaceDefinition::new([("typename".parse().unwrap(), etype)], []);
     assert_typechecks_for_mode(
@@ -97,6 +99,7 @@ fn slot_equals_typechecks() {
         member_of_types: vec![],
         shape: json_schema::AttributesOrContext::default(),
         tags: None,
+        annotations: Annotations::new(),
     };
     // These don't typecheck in strict mode because the test_util expression
     // typechecker doesn't have access to a schema, so it can't link