Project import generated by Copybara.

GitOrigin-RevId: e98f48dda572903bf2f3982cbc35a01a1b83d29f
Change-Id: I35b8f1dc1f34013726c538d89ac50a00091efa51
diff --git a/.dockerignore b/.dockerignore
index 8388195..916aafb 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,6 +1,8 @@
 **
 
 !common/
+!nearby/common/
+!nearby/identity/
 common/target/
 !nearby/.cargo
 !nearby/build_scripts/
diff --git a/.gitignore b/.gitignore
index b49ad4a..67bbbb7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@
 nearby/presence/ldt_np_jni/java/LdtNpJni/build/
 nearby/presence/ldt_np_jni/java/LdtNpJni/bin/
 **/auth_token.txt
+**/.DS_Store
 /bazel-*
 /.clwb/.bazelproject
 /common/1p_internal/ph_client_sys/portable_ph_fake/cmake-build-debug/
diff --git a/Dockerfile b/Dockerfile
index f5d2a54..842c455 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -37,7 +37,7 @@
 # install cargo with default settings
 RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
 ENV PATH="/root/.cargo/bin:${PATH}"
-RUN rustup toolchain install 1.79.0 --component llvm-tools-preview
+RUN rustup toolchain install 1.88.0 --component llvm-tools-preview
 RUN rustup toolchain install nightly-2024-09-03 --component rust-src --force
 RUN rustup target add wasm32-unknown-unknown # used by np_web
 RUN rustup target add thumbv7m-none-eabi
diff --git a/betosync/rust-toolchain.toml b/betosync/rust-toolchain.toml
index 0193dee..e88baf1 100644
--- a/betosync/rust-toolchain.toml
+++ b/betosync/rust-toolchain.toml
@@ -1,2 +1,2 @@
 [toolchain]
-channel = "1.83.0"
+channel = "1.88.0"
diff --git a/betosync/submerge/crdt/examples/simulation_integer_max/main.rs b/betosync/submerge/crdt/examples/simulation_integer_max/main.rs
index d83dfeb..bae32e7 100644
--- a/betosync/submerge/crdt/examples/simulation_integer_max/main.rs
+++ b/betosync/submerge/crdt/examples/simulation_integer_max/main.rs
@@ -21,7 +21,7 @@
         test_fakes::TriState,
     },
     delta::{AsDeltaMut, AsDeltaRef, DeltaMut},
-    CrdtState,
+    CrdtState, MergeError,
 };
 
 /// Our trivial example CRDT.
@@ -30,8 +30,8 @@
 
 /// Merges by taking the max of the two values.
 impl CrdtState for IntegerMaxCrdt {
-    fn merge(a: &Self, b: &Self) -> Self {
-        a.max(b).clone()
+    fn merge(a: &Self, b: &Self) -> Result<Self, MergeError> {
+        Ok(a.max(b).clone())
     }
 }
 
@@ -47,7 +47,7 @@
     fn apply(self, mut state: DeltaMut<IntegerMaxCrdt>, _ctx: &SimulationContext<TriState>) {
         match self {
             IntegerMaxOp::SetInteger { value } => {
-                if value > state.as_ref().merge().0 {
+                if value > state.as_ref().merge().expect("merge must not fail").0 {
                     state.delta_mut().0 = value;
                 }
             }
diff --git a/betosync/submerge/crdt/fuzz/src/bin/default_bottom_state.rs b/betosync/submerge/crdt/fuzz/src/bin/default_bottom_state.rs
index 86788d9..7e51a19 100644
--- a/betosync/submerge/crdt/fuzz/src/bin/default_bottom_state.rs
+++ b/betosync/submerge/crdt/fuzz/src/bin/default_bottom_state.rs
@@ -44,19 +44,34 @@
 fn default_bottom_state(crdt: ArbitraryCrdt) {
     match crdt {
         ArbitraryCrdt::Set(original) => {
-            assert_eq!(original, CrdtState::merge(&original, &Default::default()));
+            assert_eq!(
+                original,
+                CrdtState::merge(&original, &Default::default()).expect("merge must not fail")
+            );
         }
         ArbitraryCrdt::Register(original) => {
-            assert_eq!(original, CrdtState::merge(&original, &Default::default()));
+            assert_eq!(
+                original,
+                CrdtState::merge(&original, &Default::default()).expect("merge must not fail")
+            );
         }
         ArbitraryCrdt::VectorData(original) => {
-            assert_eq!(original, CrdtState::merge(&original, &Default::default()));
+            assert_eq!(
+                original,
+                CrdtState::merge(&original, &Default::default()).expect("merge must not fail")
+            );
         }
         ArbitraryCrdt::Map(original) => {
-            assert_eq!(original, CrdtState::merge(&original, &Default::default()));
+            assert_eq!(
+                original,
+                CrdtState::merge(&original, &Default::default()).expect("merge must not fail")
+            );
         }
         ArbitraryCrdt::Container(original) => {
-            assert_eq!(original, CrdtState::merge(&original, &Default::default()));
+            assert_eq!(
+                original,
+                CrdtState::merge(&original, &Default::default()).expect("merge must not fail")
+            );
         }
     }
 }
diff --git a/betosync/submerge/crdt/fuzz/src/bin/delta_lww_crdt_container.rs b/betosync/submerge/crdt/fuzz/src/bin/delta_lww_crdt_container.rs
index edd1a24..6fa08da 100644
--- a/betosync/submerge/crdt/fuzz/src/bin/delta_lww_crdt_container.rs
+++ b/betosync/submerge/crdt/fuzz/src/bin/delta_lww_crdt_container.rs
@@ -84,5 +84,5 @@
             .get_value_mut(&ctx)
             .map(|result| result.map(|d| d.merge())),
     );
-    assert_eq!(expected, delta.merge());
+    assert_eq!(expected, delta.merge().expect("merge must not fail"));
 }
diff --git a/betosync/submerge/crdt/fuzz/src/bin/delta_lww_map.rs b/betosync/submerge/crdt/fuzz/src/bin/delta_lww_map.rs
index 37f1c4d..6c48872 100644
--- a/betosync/submerge/crdt/fuzz/src/bin/delta_lww_map.rs
+++ b/betosync/submerge/crdt/fuzz/src/bin/delta_lww_map.rs
@@ -88,5 +88,5 @@
             .map(|k| (k, delta.get_value(k).map(|f| f.merge())))
             .collect::<Vec<_>>()
     );
-    assert_eq!(expected, delta.merge());
+    assert_eq!(expected, delta.merge().expect("merge must not fail"));
 }
diff --git a/betosync/submerge/crdt/fuzz/src/bin/delta_register.rs b/betosync/submerge/crdt/fuzz/src/bin/delta_register.rs
index 1c688af..0142bf6 100644
--- a/betosync/submerge/crdt/fuzz/src/bin/delta_register.rs
+++ b/betosync/submerge/crdt/fuzz/src/bin/delta_register.rs
@@ -43,5 +43,5 @@
 
     assert_eq!(expected.get_all(), delta.get_all());
     assert_eq!(expected.get(), delta.get());
-    assert_eq!(expected, delta.merge());
+    assert_eq!(expected, delta.merge().expect("merge must not fail"));
 }
diff --git a/betosync/submerge/crdt/fuzz/src/bin/delta_set.rs b/betosync/submerge/crdt/fuzz/src/bin/delta_set.rs
index 984e3c7..1b18318 100644
--- a/betosync/submerge/crdt/fuzz/src/bin/delta_set.rs
+++ b/betosync/submerge/crdt/fuzz/src/bin/delta_set.rs
@@ -42,5 +42,5 @@
     }
 
     assert_eq!(expected.entries(), delta.entries());
-    assert_eq!(expected, delta.merge());
+    assert_eq!(expected, delta.merge().expect("merge must not fail"));
 }
diff --git a/betosync/submerge/crdt/fuzz/src/bin/delta_vector_data.rs b/betosync/submerge/crdt/fuzz/src/bin/delta_vector_data.rs
index 8ec0c7a..8de0cc9 100644
--- a/betosync/submerge/crdt/fuzz/src/bin/delta_vector_data.rs
+++ b/betosync/submerge/crdt/fuzz/src/bin/delta_vector_data.rs
@@ -41,5 +41,5 @@
     }
 
     assert_eq!(expected.entries(), delta.entries());
-    assert_eq!(expected, delta.merge());
+    assert_eq!(expected, delta.merge().expect("merge must not fail"));
 }
diff --git a/betosync/submerge/crdt/src/checker/mod.rs b/betosync/submerge/crdt/src/checker/mod.rs
index 06a1b3f..7d6f95b 100644
--- a/betosync/submerge/crdt/src/checker/mod.rs
+++ b/betosync/submerge/crdt/src/checker/mod.rs
@@ -55,15 +55,14 @@
 /// This example implements a last-writer-wins register that requires globally unique timestamps.
 ///
 /// ```
-/// use crdt::CrdtState;
-/// use crdt::checker::MergeInvariantTest;
+/// use crdt::{checker::MergeInvariantTest, CrdtState, MergeError};
 ///
 /// #[derive(Debug, Clone, PartialEq, Eq)]
 /// pub struct LwwValue { timestamp: u64, value: String }
 ///
 /// impl CrdtState for LwwValue {
-///     fn merge(a: &Self, b: &Self) -> Self {
-///         std::cmp::max_by_key(a, b, |v| v.timestamp).clone()
+///     fn merge(a: &Self, b: &Self) -> Result<Self, MergeError> {
+///         Ok(std::cmp::max_by_key(a, b, |v| v.timestamp).clone())
 ///     }
 ///
 ///     #[cfg(any(test, feature = "checker"))]
@@ -98,20 +97,26 @@
         match self {
             Self::Associativity(s1, s2, s3) => {
                 assert_eq!(
-                    S::merge(&S::merge(&s1, &s2), &s3),
-                    S::merge(&s1, &S::merge(&s2, &s3)),
+                    S::merge(&S::merge(&s1, &s2).expect("merge must not fail"), &s3)
+                        .expect("merge must not fail"),
+                    S::merge(&s1, &S::merge(&s2, &s3).expect("merge must not fail"))
+                        .expect("merge must not fail"),
                     "Associativity violated:\ns1={s1:#?}\ns2={s2:#?}\ns3={s3:#?}",
                 )
             }
             Self::Commutativity(s1, s2) => {
                 assert_eq!(
-                    S::merge(&s1, &s2),
-                    S::merge(&s2, &s1),
+                    S::merge(&s1, &s2).expect("merge must not fail"),
+                    S::merge(&s2, &s1).expect("merge must not fail"),
                     "Commutativity violated:\ns1={s1:#?}\ns2={s2:#?}"
                 )
             }
             Self::Idempotence(s1) => {
-                assert_eq!(S::merge(&s1, &s1), s1, "Idempotence violated:\ns1={s1:#?}")
+                assert_eq!(
+                    S::merge(&s1, &s1).expect("merge must not fail"),
+                    s1,
+                    "Idempotence violated:\ns1={s1:#?}"
+                )
             }
         }
     }
diff --git a/betosync/submerge/crdt/src/checker/simulation.rs b/betosync/submerge/crdt/src/checker/simulation.rs
index 001e255..dc72040 100644
--- a/betosync/submerge/crdt/src/checker/simulation.rs
+++ b/betosync/submerge/crdt/src/checker/simulation.rs
@@ -76,7 +76,7 @@
 ///         test_fakes::TriState,
 ///     },
 ///     delta::{AsDeltaMut, AsDeltaRef, DeltaMut},
-///     CrdtState,
+///     CrdtState, MergeError,
 /// };
 ///
 /// /// Our trivial example CRDT.
@@ -85,8 +85,8 @@
 ///
 /// /// Merges by taking the max of the two values.
 /// impl CrdtState for IntegerMaxCrdt {
-///     fn merge(a: &Self, b: &Self) -> Self {
-///         a.max(b).clone()
+///     fn merge(a: &Self, b: &Self) -> Result<Self, MergeError> {
+///         Ok(a.max(b).clone())
 ///     }
 /// }
 ///
@@ -102,7 +102,7 @@
 ///     fn apply(self, mut state: DeltaMut<IntegerMaxCrdt>, _ctx: &SimulationContext<TriState>) {
 ///         match self {
 ///             IntegerMaxOp::SetInteger { value } => {
-///                 if value > state.as_ref().merge().0 {
+///                 if value > state.as_ref().merge().unwrap().0 {
 ///                     state.delta_mut().0 = value;
 ///                 }
 ///             }
@@ -145,7 +145,8 @@
                     let mut delta = DeltaOwned::new(&current_state);
                     op.apply(delta.as_mut(), &context);
                     let delta_component = delta.into_delta();
-                    current_state = CrdtState::merge(&current_state, &delta_component);
+                    current_state = CrdtState::merge(&current_state, &delta_component)
+                        .expect("merge must not fail");
                     update_messages.push(delta_component);
                 }
                 SimulationOperation::ContextOp(op) => {
@@ -172,10 +173,14 @@
         let delivered_messages = self.delivery_scenario.scramble(&sent_messages)?;
         let state1 = sent_messages
             .into_iter()
-            .fold(self.init_state.clone(), |a, b| CrdtState::merge(&a, &b));
+            .fold(self.init_state.clone(), |a, b| {
+                CrdtState::merge(&a, &b).expect("merge must not fail")
+            });
         let state2 = delivered_messages
             .into_iter()
-            .fold(self.init_state.clone(), |a, b| CrdtState::merge(&a, &b));
+            .fold(self.init_state.clone(), |a, b| {
+                CrdtState::merge(&a, &b).expect("merge must not fail")
+            });
 
         assert_eq!(state1, state2);
         assert_eq!(state1, final_state);
diff --git a/betosync/submerge/crdt/src/checker/test_crdt_union.rs b/betosync/submerge/crdt/src/checker/test_crdt_union.rs
index b4cbce6..451ab8e 100644
--- a/betosync/submerge/crdt/src/checker/test_crdt_union.rs
+++ b/betosync/submerge/crdt/src/checker/test_crdt_union.rs
@@ -20,8 +20,8 @@
 
 use crate::{
     delta::{AsDeltaMut, AsDeltaRef, DeltaMut, DeltaRef},
-    lww_crdt_container::{CrdtUnion, IncompatibleType, TryAsChild},
-    ApplyChanges, CrdtState, HasPlainRepresentation, ToPlain, UpdateContext,
+    lww_crdt_container::{CrdtUnion, TryAsChild},
+    ApplyChanges, CrdtState, HasPlainRepresentation, MergeError, ToPlain, UpdateContext,
 };
 use arbitrary::Arbitrary;
 use distributed_time::vector_clock::VectorClock;
@@ -59,11 +59,11 @@
     /// Combines the base part and the delta part and return the resulting merged component.
     ///
     /// See [`CrdtState::merge`] for details on the merge operation.
-    pub fn merge(&self) -> TestCrdtUnion {
-        match self {
-            Self::Type1(r) => TestCrdtUnion::Type1(r.merge()),
-            Self::Type2(r) => TestCrdtUnion::Type2(r.merge()),
-        }
+    pub fn merge(&self) -> Result<TestCrdtUnion, crate::MergeError> {
+        Ok(match self {
+            Self::Type1(r) => TestCrdtUnion::Type1(r.merge()?),
+            Self::Type2(r) => TestCrdtUnion::Type2(r.merge()?),
+        })
     }
 }
 
@@ -79,11 +79,11 @@
     /// Combines the base part and the delta part and return the resulting merged component.
     ///
     /// See [`CrdtState::merge`] for details on the merge operation.
-    pub fn merge(&self) -> TestCrdtUnion {
-        match self {
-            Self::Type1(r) => TestCrdtUnion::Type1(r.as_ref().merge()),
-            Self::Type2(r) => TestCrdtUnion::Type2(r.as_ref().merge()),
-        }
+    pub fn merge(&self) -> Result<TestCrdtUnion, crate::MergeError> {
+        Ok(match self {
+            Self::Type1(r) => TestCrdtUnion::Type1(r.as_ref().merge()?),
+            Self::Type2(r) => TestCrdtUnion::Type2(r.as_ref().merge()?),
+        })
     }
 }
 
@@ -92,8 +92,8 @@
 pub struct TestVariant1(pub u8);
 
 impl CrdtState for TestVariant1 {
-    fn merge(a: &Self, b: &Self) -> Self {
-        Self(a.0.max(b.0))
+    fn merge(a: &Self, b: &Self) -> Result<Self, crate::MergeError> {
+        Ok(Self(a.0.max(b.0)))
     }
 }
 
@@ -116,8 +116,8 @@
 pub struct TestVariant2(pub u8);
 
 impl CrdtState for TestVariant2 {
-    fn merge(a: &Self, b: &Self) -> Self {
-        Self(a.0.max(b.0))
+    fn merge(a: &Self, b: &Self) -> Result<Self, crate::MergeError> {
+        Ok(Self(a.0.max(b.0)))
     }
 }
 
@@ -171,7 +171,7 @@
     type DeltaRef<'d> = TestCrdtUnionRef<'d>;
     type DeltaMut<'d> = TestCrdtUnionMut<'d>;
 
-    fn try_merge(a: &Self, b: &Self) -> Result<Self, IncompatibleType> {
+    fn try_merge(a: &Self, b: &Self) -> Result<Self, MergeError> {
         match (a, b) {
             (TestCrdtUnion::Type1(state_a), TestCrdtUnion::Type1(state_b)) => {
                 Ok(TestCrdtUnion::Type1(*state_a.max(state_b)))
@@ -179,7 +179,7 @@
             (TestCrdtUnion::Type2(state_a), TestCrdtUnion::Type2(state_b)) => {
                 Ok(TestCrdtUnion::Type2(*state_a.max(state_b)))
             }
-            _ => Err(IncompatibleType),
+            _ => Err(MergeError::IncompatibleType),
         }
     }
 
@@ -277,7 +277,7 @@
     type Plain = TestCrdtUnion;
 
     fn to_plain(&self) -> TestCrdtUnion {
-        self.merge()
+        self.merge().expect("merge must not fail")
     }
 }
 
diff --git a/betosync/submerge/crdt/src/checker/tests/integer_max_merge.rs b/betosync/submerge/crdt/src/checker/tests/integer_max_merge.rs
index a780f2d..3b4d321 100644
--- a/betosync/submerge/crdt/src/checker/tests/integer_max_merge.rs
+++ b/betosync/submerge/crdt/src/checker/tests/integer_max_merge.rs
@@ -12,15 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-use crate::{checker::MergeInvariantTest, CrdtState};
+use crate::{checker::MergeInvariantTest, CrdtState, MergeError};
 use arbitrary::Arbitrary;
 
 #[derive(Debug, Clone, PartialEq, Eq, Arbitrary)]
 struct Counter(u8);
 
 impl CrdtState for Counter {
-    fn merge(a: &Self, b: &Self) -> Self {
-        Counter(a.0.max(b.0))
+    fn merge(a: &Self, b: &Self) -> Result<Self, MergeError> {
+        Ok(Counter(a.0.max(b.0)))
     }
 }
 
diff --git a/betosync/submerge/crdt/src/checker/tests/set_union_merge.rs b/betosync/submerge/crdt/src/checker/tests/set_union_merge.rs
index 9461e7c..9d722ab 100644
--- a/betosync/submerge/crdt/src/checker/tests/set_union_merge.rs
+++ b/betosync/submerge/crdt/src/checker/tests/set_union_merge.rs
@@ -16,7 +16,7 @@
 
 use crate::{
     checker::{utils::DeterministicHasher, MergeInvariantTest},
-    CrdtState,
+    CrdtState, MergeError,
 };
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -31,8 +31,8 @@
 }
 
 impl CrdtState for SetUnion {
-    fn merge(a: &Self, b: &Self) -> Self {
-        SetUnion(a.0.union(&b.0).cloned().collect())
+    fn merge(a: &Self, b: &Self) -> Result<Self, MergeError> {
+        Ok(SetUnion(a.0.union(&b.0).cloned().collect()))
     }
 }
 
diff --git a/betosync/submerge/crdt/src/delta.rs b/betosync/submerge/crdt/src/delta.rs
index e93db92..b5d6d63 100644
--- a/betosync/submerge/crdt/src/delta.rs
+++ b/betosync/submerge/crdt/src/delta.rs
@@ -54,14 +54,14 @@
     /// Combines the base part and the delta part and return the resulting merged component.
     ///
     /// See [`CrdtState::merge`] for details on the merge operation.
-    pub fn merge(&self) -> T
+    pub fn merge(&self) -> Result<T, crate::MergeError>
     where
         T: CrdtState + Clone + Default,
     {
         match (self.base, self.delta) {
-            (None, None) => T::default(),
-            (None, Some(delta)) => delta.clone(),
-            (Some(base), None) => base.clone(),
+            (None, None) => Ok(T::default()),
+            (None, Some(delta)) => Ok(delta.clone()),
+            (Some(base), None) => Ok(base.clone()),
             (Some(base), Some(delta)) => T::merge(base, delta),
         }
     }
@@ -153,7 +153,7 @@
     /// Merges the base and the delta components of this.
     ///
     /// See [`CrdtState::merge`] for details on the merge operation.
-    pub fn merge(&self) -> T
+    pub fn merge(&self) -> Result<T, crate::MergeError>
     where
         T: CrdtState,
     {
diff --git a/betosync/submerge/crdt/src/lib.rs b/betosync/submerge/crdt/src/lib.rs
index df9f693..8fa2e9a 100644
--- a/betosync/submerge/crdt/src/lib.rs
+++ b/betosync/submerge/crdt/src/lib.rs
@@ -22,6 +22,7 @@
     vector_clock::VectorClock, DistributedClock, TimestampOverflow, WallTimestampProvider,
 };
 use lww_crdt_container::CrdtUnion;
+use thiserror::Error;
 
 pub mod lww_crdt_container;
 pub mod lww_map;
@@ -36,11 +37,24 @@
 pub mod delta;
 mod utils;
 
+/// Error returned when a CRDT merge fails.
+///
+/// When a merge error occurs, the CRDTs cannot be combined, and data convergence is not guaranteed.
+#[derive(Debug, Error, PartialEq, Eq, Clone, Copy)]
+pub enum MergeError {
+    /// Merge failed because the types are incompatible.
+    #[error("incompatible types")]
+    IncompatibleType,
+    /// Merge failed because the values are inconsistent.
+    #[error("inconsistent values")]
+    InconsistentValue,
+}
+
 /// A CRDT state that is mergeable with instance. The [`merge`][CrdtState::merge] operation must be:
 ///
-/// * associative: `merge(merge(a, b), c) == merge(a, merge(b, c))`, and
-/// * commutative: `merge(a, b) == merge(b, a)`, and
-/// * idempotent: `merge(a, a) == a`
+/// * associative: `merge(merge(a, b)?, c)? == merge(a, merge(b, c)?)?`, and
+/// * commutative: `merge(a, b)? == merge(b, a)?`, and
+/// * idempotent: `merge(a, a)? == a`
 ///
 /// These properties can be verified using
 /// [`MergeInvariantTest`][crate::checker::MergeInvariantTest]. Any implementations of `CrdtState`
@@ -53,9 +67,9 @@
 /// See also:
 /// * [A comprehensive study of Convergent and Commutative Replicated Data
 ///   Types](https://inria.hal.science/inria-00555588/document)
-pub trait CrdtState {
+pub trait CrdtState: Sized {
     /// Merges two CRDT states of the same type. See the trait documentation for details.
-    fn merge(a: &Self, b: &Self) -> Self;
+    fn merge(a: &Self, b: &Self) -> Result<Self, MergeError>;
 
     /// Return whether a given collection of CRDTs are valid.
     ///
diff --git a/betosync/submerge/crdt/src/lww_crdt_container.rs b/betosync/submerge/crdt/src/lww_crdt_container.rs
index 8d9d8f0..a556dac 100644
--- a/betosync/submerge/crdt/src/lww_crdt_container.rs
+++ b/betosync/submerge/crdt/src/lww_crdt_container.rs
@@ -30,6 +30,8 @@
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
+use crate::MergeError;
+
 /// Error returned when a [`CrdtUnion`] cannot be merged.
 #[derive(Debug)]
 pub struct IncompatibleType;
@@ -65,7 +67,7 @@
     /// If `a` and `b` are of the same variant, it should return
     /// `Self::from(CrdtState::merge(a.as_child(), b.as_child()))`. Otherwise, it should return
     /// `Err(IncompatibleType)`.
-    fn try_merge(a: &Self, b: &Self) -> Result<Self, IncompatibleType>;
+    fn try_merge(a: &Self, b: &Self) -> Result<Self, MergeError>;
 
     /// Create a new read-only delta reference from the given `base` and `delta` components.
     fn create_ref<'d>(
@@ -593,47 +595,38 @@
     T: TotalTimestamp + Clone,
     N: Ord + Clone,
 {
-    fn merge(a: &Self, b: &Self) -> Self {
+    fn merge(a: &Self, b: &Self) -> Result<Self, MergeError> {
         fn merge_inner<V, T, N: Ord>(
             a: &LwwCrdtContainerInner<V, T>,
             b: &LwwCrdtContainerInner<V, T>,
-        ) -> LwwCrdtContainerInner<V, T>
+        ) -> Result<LwwCrdtContainerInner<V, T>, MergeError>
         where
             V: CrdtUnion<N> + Clone,
             T: TotalTimestamp + Clone,
         {
-            match a.timestamp.cmp(&b.timestamp) {
-                Ordering::Equal => {
-                    match (&a.value, &b.value) {
-                        (Some(value_a), Some(value_b)) => LwwCrdtContainerInner {
-                            value: Some(
-                                CrdtUnion::try_merge(value_a, value_b)
-                                    // This container does not allow updating the value without also
-                                    // updating the timestamp outside of mutating a child CRDT, and
-                                    // `try_merge` is required to delegate to the child CRDT's
-                                    // `merge` which is infallible.
-                                    .expect("Values with the same timestamp should be mergeable."),
-                            ),
-                            timestamp: a.timestamp.clone(),
-                        },
-                        (Some(_), None) => a.clone(),
-                        (None, Some(_)) => b.clone(),
-                        (None, None) => a.clone(),
-                    }
-                }
+            Ok(match a.timestamp.cmp(&b.timestamp) {
+                Ordering::Equal => match (&a.value, &b.value) {
+                    (Some(value_a), Some(value_b)) => LwwCrdtContainerInner {
+                        value: Some(CrdtUnion::try_merge(value_a, value_b)?),
+                        timestamp: a.timestamp.clone(),
+                    },
+                    (Some(_), None) => a.clone(),
+                    (None, Some(_)) => b.clone(),
+                    (None, None) => a.clone(),
+                },
                 Ordering::Less => b.clone(),
                 Ordering::Greater => a.clone(),
-            }
+            })
         }
 
-        LwwCrdtContainer {
+        Ok(LwwCrdtContainer {
             value: match (&a.value, &b.value) {
-                (Some(inner_a), Some(inner_b)) => Some(merge_inner(inner_a, inner_b)),
+                (Some(inner_a), Some(inner_b)) => Some(merge_inner(inner_a, inner_b)?),
                 (Some(inner), None) | (None, Some(inner)) => Some(inner.clone()),
                 (None, None) => None,
             },
             subtree_version: VectorClock::least_upper_bound(&a.subtree_version, &b.subtree_version),
-        }
+        })
     }
 
     #[cfg(any(test, feature = "checker"))]
@@ -760,6 +753,7 @@
 
     #[cfg(test)]
     #[derive_fuzztest::proptest]
+    #[allow(clippy::unwrap_used)]
     fn container_roundtrip(
         container: LwwCrdtContainer<
             TypedCrdt<Vec<u8>, String>,
@@ -790,7 +784,7 @@
         set::Set,
         typed_crdt::{TypedCrdt, TypedCrdtRef},
         vector_data::VectorData,
-        CrdtState,
+        CrdtState, MergeError,
     };
 
     use super::LwwCrdtContainer;
@@ -846,7 +840,7 @@
             .set_value(&FakeContext::new(1, 2_u8), VectorData::default().into())
             .unwrap();
 
-        let merged = CrdtState::merge(&container1, &container2);
+        let merged = CrdtState::merge(&container1, &container2).unwrap();
         // container2 wins because its timestamp is larger
         assert!(matches!(
             merged.get_value(),
@@ -868,7 +862,7 @@
             .set_value(&ctx2, VectorData::default().into())
             .unwrap();
 
-        let merged = CrdtState::merge(&container1, &container2);
+        let merged = CrdtState::merge(&container1, &container2).unwrap();
         // d2 wins even though its timestamp is smaller, because it has observed d1 (d2 is cloned
         // from d1)
         assert!(matches!(
@@ -908,7 +902,7 @@
             .unwrap();
         child_reg1.set(&FakeContext::new(0, 10_u8), "abcd").unwrap();
 
-        let merged = CrdtState::merge(&container1, &container2);
+        let merged = CrdtState::merge(&container1, &container2).unwrap();
         let merged_child_reg = merged.get_child::<Register<_, _>>().unwrap();
         // Merge should be done by Register rules, which is to keep all conflicting values.
         // "5678" comes first because it is newer (timestamp value of 12)
@@ -992,4 +986,21 @@
             vec!["#1", "#0"]
         );
     }
+
+    #[test]
+    fn test_merge_incompatible_type() {
+        let mut container1 = TestContainer::default();
+        let ctx = FakeContext::new(0, 0_u8);
+        container1
+            .set_value(&ctx, Register::default().into())
+            .unwrap();
+
+        let mut container2 = TestContainer::default();
+        container2.set_value(&ctx, Set::default().into()).unwrap();
+
+        assert_eq!(
+            CrdtState::merge(&container1, &container2).unwrap_err(),
+            MergeError::IncompatibleType
+        );
+    }
 }
diff --git a/betosync/submerge/crdt/src/lww_map.rs b/betosync/submerge/crdt/src/lww_map.rs
index a48d14f..1f576de 100644
--- a/betosync/submerge/crdt/src/lww_map.rs
+++ b/betosync/submerge/crdt/src/lww_map.rs
@@ -347,20 +347,20 @@
     T: TotalTimestamp + Clone,
     N: Ord + Clone,
 {
-    fn merge(a: &Self, b: &Self) -> Self {
-        Self {
+    fn merge(a: &Self, b: &Self) -> Result<Self, crate::MergeError> {
+        Ok(Self {
             elements: zip_btree_map(&a.elements, &b.elements)
                 .map(|(key, item)| {
-                    (
+                    Ok((
                         key.clone(),
                         match item {
                             ZipItem::Left(value) | ZipItem::Right(value) => value.clone(),
-                            ZipItem::Both(value_a, value_b) => CrdtState::merge(value_a, value_b),
+                            ZipItem::Both(value_a, value_b) => CrdtState::merge(value_a, value_b)?,
                         },
-                    )
+                    ))
                 })
-                .collect(),
-        }
+                .collect::<Result<_, _>>()?,
+        })
     }
 
     #[cfg(any(test, feature = "checker"))]
@@ -462,6 +462,7 @@
 
     #[cfg(test)]
     #[derive_fuzztest::proptest]
+    #[allow(clippy::unwrap_used)]
     fn map_roundtrip(
         map: LwwMap<String, TypedCrdt<Vec<u8>, String>, HybridLogicalTimestamp<String>, String>,
     ) {
diff --git a/betosync/submerge/crdt/src/register.rs b/betosync/submerge/crdt/src/register.rs
index 183eb0b..f8165ea 100644
--- a/betosync/submerge/crdt/src/register.rs
+++ b/betosync/submerge/crdt/src/register.rs
@@ -289,10 +289,10 @@
     V: Clone,
     D: DistributedClock + NonSemanticOrd + Clone + Default,
 {
-    fn merge(a: &Self, b: &Self) -> Self {
-        Self {
+    fn merge(a: &Self, b: &Self) -> Result<Self, crate::MergeError> {
+        Ok(Self {
             elements: Self::merge_elements(&a.elements, &b.elements),
-        }
+        })
     }
 
     #[cfg(any(test, feature = "checker"))]
@@ -466,6 +466,7 @@
 
     #[cfg(test)]
     #[derive_fuzztest::proptest]
+    #[allow(clippy::unwrap_used)]
     fn register_roundtrip(register: Register<Vec<u8>, VectorClock<String>>) {
         let mut node_ids = NodeMapping::default();
         assert_eq!(
@@ -504,8 +505,8 @@
 
         let r3: TestRegister = Register::new(&FakeContext::new(2, 2_u8), 3)?;
 
-        let r_merged = CrdtState::merge(&r1, &r2);
-        let r_merged = CrdtState::merge(&r_merged, &r3);
+        let r_merged = CrdtState::merge(&r1, &r2).unwrap();
+        let r_merged = CrdtState::merge(&r_merged, &r3).unwrap();
 
         // Ordered from oldest to newest according to timestamp, tie-broken by node ID
         assert_eq!(vec![&3, &1, &2], r_merged.get_all());
@@ -522,7 +523,7 @@
         let r2: TestRegister = Register::new(&FakeContext::new(0, 2_u8), 1)?;
 
         // Merging r2 doesn't change r1 because r1 has already observed that change.
-        let r_merged = CrdtState::merge(&r1, &r2);
+        let r_merged = CrdtState::merge(&r1, &r2).unwrap();
 
         assert_eq!(vec![&2], r_merged.get_all());
         assert_eq!(Some(&2), r_merged.get());
@@ -534,7 +535,7 @@
     fn test_merge_same() {
         let r1: TestRegister = Register::new(&FakeContext::new(0, 0_u8), 1).unwrap();
 
-        let r_merged = CrdtState::merge(&r1, &r1);
+        let r_merged = CrdtState::merge(&r1, &r1).unwrap();
 
         assert_eq!(vec![&1], r_merged.get_all());
         assert_eq!(Some(&1), r_merged.get());
diff --git a/betosync/submerge/crdt/src/set.rs b/betosync/submerge/crdt/src/set.rs
index c490af2..7eddf23 100644
--- a/betosync/submerge/crdt/src/set.rs
+++ b/betosync/submerge/crdt/src/set.rs
@@ -358,8 +358,8 @@
     E: Hash + Eq + Clone,
     S: BuildHasher + Default,
 {
-    fn merge(a: &Self, b: &Self) -> Self {
-        Self {
+    fn merge(a: &Self, b: &Self) -> Result<Self, crate::MergeError> {
+        Ok(Self {
             elements: {
                 zip_hash_map(&a.elements, &b.elements)
                     .map(|(key, item)| match item {
@@ -368,7 +368,7 @@
                     })
                     .collect()
             },
-        }
+        })
     }
 }
 
@@ -509,6 +509,7 @@
 
     #[cfg(test)]
     #[derive_fuzztest::proptest]
+    #[allow(clippy::unwrap_used)]
     fn set_roundtrip(set: Set<Vec<u8>>) {
         let mut node_ids = NodeMapping::default();
         assert_eq!(
@@ -582,7 +583,7 @@
         let _ = set2.add(3).unwrap();
         let _ = set2.add(4).unwrap();
 
-        let merged = CrdtState::merge(&set1, &set2);
+        let merged = CrdtState::merge(&set1, &set2).unwrap();
 
         assert_eq!(HashSet::from_iter(&[1, 2, 3, 4]), merged.entries());
     }
@@ -596,7 +597,7 @@
 
         let set2: TestSet = Set::default();
 
-        let mut set2 = CrdtState::merge(&set1, &set2);
+        let mut set2 = CrdtState::merge(&set1, &set2).unwrap();
         let _ = set2.remove(2);
 
         assert_eq!(HashSet::from_iter(&[1, 3]), set2.entries());
diff --git a/betosync/submerge/crdt/src/typed_crdt.rs b/betosync/submerge/crdt/src/typed_crdt.rs
index 83163c9..f8c7ede 100644
--- a/betosync/submerge/crdt/src/typed_crdt.rs
+++ b/betosync/submerge/crdt/src/typed_crdt.rs
@@ -42,8 +42,15 @@
     register::{Register, RegisterError},
     set::{GenerationExhausted, Set},
     vector_data::{VectorData, VectorDataError},
+    MergeError,
 };
 
+impl From<MergeError> for IncompatibleType {
+    fn from(_: MergeError) -> Self {
+        IncompatibleType
+    }
+}
+
 /// An enum of the CRDT types supported by this crate.
 ///
 /// In this data model, the main structural component is the `Map`, which has a string key and a
@@ -281,22 +288,22 @@
     type DeltaRef<'d> = TypedCrdtRef<'d, V, N, S>;
     type DeltaMut<'d> = TypedCrdtMut<'d, V, N, S>;
 
-    fn try_merge(a: &Self, b: &Self) -> Result<Self, IncompatibleType> {
-        match (a, b) {
+    fn try_merge(a: &Self, b: &Self) -> Result<Self, MergeError> {
+        Ok(match (a, b) {
             (TypedCrdt::Map(map_a), TypedCrdt::Map(map_b)) => {
-                Ok(TypedCrdt::Map(CrdtState::merge(map_a, map_b)))
+                TypedCrdt::Map(CrdtState::merge(map_a, map_b)?)
             }
             (TypedCrdt::Register(reg_a), TypedCrdt::Register(reg_b)) => {
-                Ok(TypedCrdt::Register(CrdtState::merge(reg_a, reg_b)))
+                TypedCrdt::Register(CrdtState::merge(reg_a, reg_b)?)
             }
             (TypedCrdt::Set(set_a), TypedCrdt::Set(set_b)) => {
-                Ok(TypedCrdt::Set(CrdtState::merge(set_a, set_b)))
+                TypedCrdt::Set(CrdtState::merge(set_a, set_b)?)
             }
             (TypedCrdt::VectorData(vec_a), TypedCrdt::VectorData(vec_b)) => {
-                Ok(TypedCrdt::VectorData(CrdtState::merge(vec_a, vec_b)))
+                TypedCrdt::VectorData(CrdtState::merge(vec_a, vec_b)?)
             }
-            _ => Err(IncompatibleType),
-        }
+            _ => return Err(MergeError::IncompatibleType),
+        })
     }
 
     fn create_ref<'d>(
@@ -576,14 +583,14 @@
     pub struct CrdtUnionMergeResult(Option<TypedCrdt<TriState, u8, DeterministicHasher>>);
 
     impl CrdtState for CrdtUnionMergeResult {
-        fn merge(a: &Self, b: &Self) -> Self {
-            match (&a.0, &b.0) {
+        fn merge(a: &Self, b: &Self) -> Result<Self, crate::MergeError> {
+            Ok(match (&a.0, &b.0) {
                 (Some(v_a), Some(v_b)) => match CrdtUnion::try_merge(v_a, v_b) {
                     Ok(v) => CrdtUnionMergeResult(Some(v)),
                     Err(_) => CrdtUnionMergeResult(None),
                 },
                 _ => CrdtUnionMergeResult(None),
-            }
+            })
         }
 
         fn is_valid_collection<'a>(collection: impl IntoIterator<Item = &'a Self>) -> bool
@@ -680,6 +687,7 @@
 
     #[cfg(test)]
     #[derive_fuzztest::proptest]
+    #[allow(clippy::unwrap_used)]
     fn typed_crdt_roundtrip(typed_crdt: TypedCrdt<Vec<u8>, String>) {
         let mut node_ids = NodeMapping::default();
         assert_eq!(
diff --git a/betosync/submerge/crdt/src/vector_data.rs b/betosync/submerge/crdt/src/vector_data.rs
index 44b81d5..799381f 100644
--- a/betosync/submerge/crdt/src/vector_data.rs
+++ b/betosync/submerge/crdt/src/vector_data.rs
@@ -240,18 +240,24 @@
     N: Ord + Clone,
     V: Clone + Eq,
 {
-    fn merge(a: &Self, b: &Self) -> Self {
-        Self {
+    fn merge(a: &Self, b: &Self) -> Result<Self, crate::MergeError> {
+        Ok(Self {
             elements: zip_btree_map(&a.elements, &b.elements)
-                .map(|(key, item)| match item {
-                    ZipItem::Left(value) | ZipItem::Right(value) => (key.clone(), value.clone()),
-                    ZipItem::Both(value_a, value_b) => (
-                        key.clone(),
-                        max_by_key(value_a, value_b, |v| v.version).clone(),
-                    ),
+                .map(|(key, item)| {
+                    let entry = match item {
+                        ZipItem::Left(value) => value.clone(),
+                        ZipItem::Right(value) => value.clone(),
+                        ZipItem::Both(value_a, value_b) => {
+                            if value_a.version == value_b.version && value_a.data != value_b.data {
+                                return Err(crate::MergeError::InconsistentValue);
+                            }
+                            max_by_key(value_a, value_b, |v| v.version).clone()
+                        }
+                    };
+                    Ok((key.clone(), entry))
                 })
-                .collect(),
-        }
+                .collect::<Result<_, _>>()?,
+        })
     }
 
     #[cfg(any(test, feature = "checker"))]
@@ -342,7 +348,7 @@
         V: Debug + PartialEq + Eq + Clone + 'static,
     {
         fn apply(self, mut state: DeltaMut<VectorData<N, V>>, ctx: &SimulationContext<N>) {
-            let before = state.as_ref().merge();
+            let before = state.as_ref().merge().expect("merge must not fail");
             let before_entries = before.entries();
             match self {
                 VecDataOp::Set { node, value } => {
@@ -433,6 +439,7 @@
 
     #[cfg(test)]
     #[derive_fuzztest::proptest]
+    #[allow(clippy::unwrap_used)]
     fn vector_data_roundtrip(vector_data: VectorData<String, Vec<u8>>) {
         let mut node_ids = NodeMapping::default();
         assert_eq!(
@@ -510,7 +517,7 @@
         let mut child2 = v_parent.clone();
         let _ = child2.set(2, Some(22)).unwrap();
 
-        let v_merged = VectorData::merge(&child1, &child2);
+        let v_merged = VectorData::merge(&child1, &child2).unwrap();
         assert_eq!(
             BTreeMap::from_iter([(&0, &88), (&1, &44), (&2, &22)]),
             v_merged.entries()
@@ -525,10 +532,10 @@
         let mut v2 = VectorData::<u8, u8>::default();
         let _ = v2.set(1, Some(44)).unwrap();
 
-        let _ = VectorData::merge(&v1, &v2);
-        // This is undefined behavior (in the generic sense of the word), it should never happen
-        // since set(1, 44) should always happen after observing set(1, 88) and thus increment the
-        // version number
+        assert_eq!(
+            VectorData::merge(&v1, &v2).unwrap_err(),
+            crate::MergeError::InconsistentValue
+        );
     }
 
     #[test]
diff --git a/betosync/submerge/distributed_time/src/compound_timestamp.rs b/betosync/submerge/distributed_time/src/compound_timestamp.rs
index 615e184..73ee762 100644
--- a/betosync/submerge/distributed_time/src/compound_timestamp.rs
+++ b/betosync/submerge/distributed_time/src/compound_timestamp.rs
@@ -221,6 +221,7 @@
 
     #[cfg(test)]
     #[derive_fuzztest::proptest]
+    #[allow(clippy::unwrap_used)]
     fn compound_timestamp_round_trip(compound_timestamp: CompoundTimestamp<VectorClock<String>>) {
         let mut node_ids = NodeMapping::default();
         let (hlc_proto, vector_clock_proto) = compound_timestamp.to_proto(&mut node_ids);
diff --git a/betosync/submerge/distributed_time/src/hybrid_logical_clock.rs b/betosync/submerge/distributed_time/src/hybrid_logical_clock.rs
index fd97ee2..c2dc932 100644
--- a/betosync/submerge/distributed_time/src/hybrid_logical_clock.rs
+++ b/betosync/submerge/distributed_time/src/hybrid_logical_clock.rs
@@ -199,6 +199,7 @@
 
     #[cfg(test)]
     #[derive_fuzztest::proptest]
+    #[allow(clippy::unwrap_used)]
     fn unnamed_hlc_round_trip(hlc: UnnamedHybridLogicalTimestamp) {
         let mut node_ids = NodeMapping::default();
         assert_eq!(
@@ -213,6 +214,7 @@
 
     #[cfg(test)]
     #[derive_fuzztest::proptest]
+    #[allow(clippy::unwrap_used)]
     fn hlc_round_trip(hlc: HybridLogicalTimestamp<String>) {
         let mut node_ids = NodeMapping::default();
         let (timestamp_proto, updater) = hlc.to_proto(&mut node_ids);
diff --git a/betosync/submerge/distributed_time/src/vector_clock.rs b/betosync/submerge/distributed_time/src/vector_clock.rs
index aa07b0f..456359d 100644
--- a/betosync/submerge/distributed_time/src/vector_clock.rs
+++ b/betosync/submerge/distributed_time/src/vector_clock.rs
@@ -193,6 +193,7 @@
 
     #[cfg(test)]
     #[derive_fuzztest::proptest]
+    #[allow(clippy::unwrap_used)]
     fn vector_clock_round_trip(vector_clock: VectorClock<String>) {
         let mut node_ids = NodeMapping::default();
         assert_eq!(
diff --git a/betosync/submerge/submerge/fuzz/Cargo.toml b/betosync/submerge/submerge/fuzz/Cargo.toml
index f9274b0..ca190f9 100644
--- a/betosync/submerge/submerge/fuzz/Cargo.toml
+++ b/betosync/submerge/submerge/fuzz/Cargo.toml
@@ -41,3 +41,7 @@
 [[bin]]
 name = "delta_round_trip"
 doc = false
+
+[[bin]]
+name = "commit_update"
+doc = false
diff --git a/betosync/submerge/submerge/fuzz/src/actor.rs b/betosync/submerge/submerge/fuzz/src/actor.rs
index ae9fb2d..4200df8 100644
--- a/betosync/submerge/submerge/fuzz/src/actor.rs
+++ b/betosync/submerge/submerge/fuzz/src/actor.rs
@@ -132,6 +132,7 @@
                 match result {
                     Ok(()) => {}
                     Err(CommitUpdateError::StorageError(_)) => unreachable!(),
+                    Err(CommitUpdateError::MergeError(_)) => unreachable!(),
                     Err(CommitUpdateError::OlderBaseNeeded {
                         required_base_version,
                     }) => {
diff --git a/betosync/submerge/submerge/fuzz/src/bin/commit_update.rs b/betosync/submerge/submerge/fuzz/src/bin/commit_update.rs
new file mode 100644
index 0000000..b195d3c
--- /dev/null
+++ b/betosync/submerge/submerge/fuzz/src/bin/commit_update.rs
@@ -0,0 +1,32 @@
+// Copyright 2024 Google LLC
+//
+// 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
+//
+//     http://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.
+
+#![cfg_attr(fuzzing, no_main)]
+
+use submerge::{DeltaApplicationError, DeltaDocument, Document};
+use submerge_fuzz::TestDataStoreConfig;
+
+#[derive_fuzztest::fuzztest]
+fn commit_update(
+    mut doc: Document<TestDataStoreConfig>,
+    update: DeltaDocument<TestDataStoreConfig>,
+) {
+    doc.version = update.base_version.clone();
+    assert!(matches!(
+        update.into_document(&doc),
+        Ok(_)
+            | Err(DeltaApplicationError::BaseVersionTooOld)
+            | Err(DeltaApplicationError::CrdtMergeError(_))
+    ));
+}
diff --git a/betosync/submerge/submerge/src/lib.rs b/betosync/submerge/submerge/src/lib.rs
index 1839af0..a4cba6f 100644
--- a/betosync/submerge/submerge/src/lib.rs
+++ b/betosync/submerge/submerge/src/lib.rs
@@ -199,29 +199,39 @@
 
 /// Error returned when a delta is not applicable to the given base.
 #[derive(Debug, Clone, Error)]
-#[error("Delta is not applicable to the given base")]
-pub struct DeltaNotApplicable;
+pub enum DeltaApplicationError {
+    /// Delta is not applicable to the given base, base version is too old.
+    #[error("Delta is not applicable to the given base, base version is too old")]
+    BaseVersionTooOld,
+    /// The CRDT merge failed.
+    #[error("The CRDT merge failed")]
+    CrdtMergeError(#[from] crdt::MergeError),
+}
 
 impl<Conf: DataStoreConfig> DeltaDocument<Conf> {
     /// Merge this delta document into the given `base`.
     ///
     /// The given `base` should be generated with [`Document::calculate_delta`] or
     /// [`DeltaOwned::into_delta`][crdt::delta::DeltaOwned::into_delta], and must be at least as new
-    /// as the [`base_version`][Self::base_version], or `Err(DeltaNotApplicable)` will be returned.
+    /// as the [`base_version`][Self::base_version], or `Err(DeltaApplicationError::BaseVersionTooOld)`
+    /// will be returned.
     ///
     /// The resulting document will contain all changes from both this delta and the `base`, merged
     /// together according to the CRDT definitions.
     pub fn into_document(
         self,
         base: &Document<Conf>,
-    ) -> Result<Document<Conf>, DeltaNotApplicable> {
+    ) -> Result<Document<Conf>, DeltaApplicationError> {
         if let None | Some(Ordering::Less) = base.version.partial_cmp(&self.base_version) {
-            Err(DeltaNotApplicable)
+            Err(DeltaApplicationError::BaseVersionTooOld)
         } else {
-            Ok(Document {
-                root: CrdtState::merge(&self.root, &base.root),
-                version: VectorClock::least_upper_bound(&self.version, &base.version),
-            })
+            match CrdtState::merge(&self.root, &base.root) {
+                Ok(root) => Ok(Document {
+                    root,
+                    version: VectorClock::least_upper_bound(&self.version, &base.version),
+                }),
+                Err(e) => Err(DeltaApplicationError::CrdtMergeError(e)),
+            }
         }
     }
 }
@@ -239,6 +249,9 @@
     /// The storage interface failed to persist the resulting data.
     #[error(transparent)]
     StorageError(StorageError<Conf>),
+    /// The CRDT merge failed.
+    #[error("The CRDT merge failed.")]
+    MergeError(#[from] crdt::MergeError),
 }
 
 impl<Conf: DataStoreConfig> From<TimestampOverflow> for TransactionError<Conf> {
@@ -439,8 +452,8 @@
     ///   handle the error.
     pub fn commit(mut self) -> Result<(), TransactionError<Conf>> {
         let handle = self.handle.borrow_mut();
-        let merged = CrdtState::merge(&handle.doc.root, &self.delta);
-        let merged_network_only = CrdtState::merge(&handle.doc.root, &self.network_delta);
+        let merged = CrdtState::merge(&handle.doc.root, &self.delta)?;
+        let merged_network_only = CrdtState::merge(&handle.doc.root, &self.network_delta)?;
         // Network change should consider both content change and subtree_version change.
         let has_network_change = merged_network_only != handle.doc.root;
         // Local change should only consider content change.
@@ -509,8 +522,11 @@
             );
             return Ok(());
         }
-        self.delta = CrdtState::merge(&self.delta, &delta.root);
-        self.network_delta = CrdtState::merge(&self.network_delta, &delta.root);
+        let new_delta = CrdtState::merge(&self.delta, &delta.root)?;
+        // Attempt merging with the base now to ensure the network delta is compatible.
+        let _ = CrdtState::merge(&self.handle.borrow_mut().doc.root, &new_delta)?;
+        self.delta = new_delta;
+        self.network_delta = CrdtState::merge(&self.network_delta, &delta.root)?;
         self.update_context.version =
             VectorClock::least_upper_bound(&self.update_context.version, &delta.version);
         Ok(())
@@ -528,6 +544,9 @@
         /// The base version needed to successfully update this data store.
         required_base_version: VectorClock<Conf::NodeId>,
     },
+    /// The CRDT merge failed.
+    #[error("The CRDT merge failed.")]
+    MergeError(#[from] crdt::MergeError),
 }
 
 /// Error type used for [`DocumentHandle::modify`].
@@ -607,7 +626,7 @@
             return Ok(());
         }
         let updated_document = Document {
-            root: CrdtState::merge(&self.doc.root, &update_message.root),
+            root: CrdtState::merge(&self.doc.root, &update_message.root)?,
             version: VectorClock::least_upper_bound(&self.doc.version, &update_message.version),
         };
         debug!(
diff --git a/betosync/submerge/submerge_java/lib/src/main/java/com/google/android/submerge/DataStore.java b/betosync/submerge/submerge_java/lib/src/main/java/com/google/android/submerge/DataStore.java
index 2b383d6..a54b327 100644
--- a/betosync/submerge/submerge_java/lib/src/main/java/com/google/android/submerge/DataStore.java
+++ b/betosync/submerge/submerge_java/lib/src/main/java/com/google/android/submerge/DataStore.java
@@ -143,10 +143,11 @@
    *     failures while trying to write to disk.
    * @throws OlderBaseNeededException if the {@code updateMessage} given doesn't cover an old enough
    *     base to be applied.
+   * @throws MergeException if the {@code updateMessage} has incompatible changes.
    * @throws RuntimeException if this transaction has already been committed.
    */
   public void commitNetworkUpdate(String docId, byte[] updateMessage)
-      throws TransactionException, OlderBaseNeededException {
+      throws TransactionException, OlderBaseNeededException, MergeException {
     synchronized (this.handle) {
       nativeCommitNetworkUpdate(this.handle.handle, docId, updateMessage);
     }
diff --git a/betosync/submerge/submerge_java/lib/src/main/java/com/google/android/submerge/DocumentTransaction.java b/betosync/submerge/submerge_java/lib/src/main/java/com/google/android/submerge/DocumentTransaction.java
index 94214fe..195ec0c 100644
--- a/betosync/submerge/submerge_java/lib/src/main/java/com/google/android/submerge/DocumentTransaction.java
+++ b/betosync/submerge/submerge_java/lib/src/main/java/com/google/android/submerge/DocumentTransaction.java
@@ -204,8 +204,9 @@
    * @param update The update message from the network.
    * @throws OlderBaseNeededException if the {@code updateMessage} given doesn't cover an old enough
    *     base to be applied.
+   * @throws MergeException if the {@code updateMessage} has incompatible changes.
    */
-  public void mergeNetworkUpdate(byte[] update) throws OlderBaseNeededException {
+  public void mergeNetworkUpdate(byte[] update) throws OlderBaseNeededException, MergeException {
     synchronized (this.handle) {
       nativeMergeNetworkUpdate(this.handle.handle, update);
     }
@@ -297,4 +298,4 @@
 
   private static native @SubmergeDataTypeHandleId long nativeNewVectorData(
       @DocumentTransactionHandleId long handle);
-}
\ No newline at end of file
+}
diff --git a/betosync/submerge/submerge_java/lib/src/main/java/com/google/submerge/MergeException.java b/betosync/submerge/submerge_java/lib/src/main/java/com/google/submerge/MergeException.java
new file mode 100644
index 0000000..2858c1c
--- /dev/null
+++ b/betosync/submerge/submerge_java/lib/src/main/java/com/google/submerge/MergeException.java
@@ -0,0 +1,30 @@
+// Copyright 2025 Google LLC
+//
+// 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
+//
+//     http://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.
+
+package com.google.android.submerge;
+
+/**
+ * Thrown when a CRDT merge fails.
+ *
+ * <p>A merge can fail when some of the peers do not follow the expected algorithm for generating
+ * update messages, or fail to validate for necessary preconditions for mutation operations.
+ * Examples include sending different updates with the same timestamp, creating a data store from
+ * corrupted update messages or persisted bytes, or different peers creating their data store from
+ * conflicting schemas.
+ */
+public class MergeException extends Exception {
+    MergeException(String message) {
+        super(message);
+    }
+}
diff --git a/betosync/submerge/submerge_java/lib/src/test/java/com/google/android/submerge/DataStoreTest.java b/betosync/submerge/submerge_java/lib/src/test/java/com/google/android/submerge/DataStoreTest.java
index ee74723..c6be498 100644
--- a/betosync/submerge/submerge_java/lib/src/test/java/com/google/android/submerge/DataStoreTest.java
+++ b/betosync/submerge/submerge_java/lib/src/test/java/com/google/android/submerge/DataStoreTest.java
@@ -17,7 +17,7 @@
 package com.google.android.submerge;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
+
 import static org.junit.Assert.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -29,11 +29,14 @@
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import androidx.annotation.Nullable;
+
 import com.google.android.submerge.StorageInterface.StorageException;
 import com.google.android.submerge.SubmergeDataType.SubmergeDataTypeHandleId;
 import com.google.errorprone.annotations.MustBeClosed;
-import java.util.HashMap;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -47,6 +50,8 @@
 import org.mockito.junit.MockitoJUnit;
 import org.mockito.junit.MockitoRule;
 
+import java.util.HashMap;
+
 @RunWith(JUnit4.class)
 public class DataStoreTest {
   @Rule public MockitoRule mockitoRule = MockitoJUnit.rule();
@@ -927,6 +932,103 @@
     assertThat(v5.compare(v1)).isEqualTo(VersionVector.NEWER);
   }
 
+  @Test
+  public void testCommitNetworkUpdate_incompatibleType() throws Exception {
+    // 1. Create two datastores for the same actor to simulate divergent states.
+    try (DataStore<String> dataStore1 = createDataStore("actor1");
+         DataStore<String> dataStore2 =
+                    new DataStore<>(
+                            "actor1",
+                            networkInterface,
+                            new Storage(),
+                            timestampProvider,
+                            new StringConverter())) {
+      // 2. Create a base document in store 1 and sync it to store 2.
+      try (DocumentTransaction<String> doc = dataStore1.newDocumentTransaction("mydoc")) {
+        doc.setRoot(doc.newRegister());
+        doc.commit();
+      }
+      byte[] baseUpdate = dataStore1.getFullUpdateMessage("mydoc");
+      dataStore2.commitNetworkUpdate("mydoc", baseUpdate);
+      VersionVector baseVersion = dataStore1.getDocumentVersion("mydoc");
+
+      // 3. Mock timestamp provider to produce identical timestamps to force a content merge.
+      when(timestampProvider.now()).thenReturn(1000L);
+
+      // 4. Create divergent updates with incompatible types from each actor.
+      try (DocumentTransaction<String> doc = dataStore1.newDocumentTransaction("mydoc")) {
+        doc.setRoot(doc.newMap());
+        doc.commit();
+      }
+
+      try (DocumentTransaction<String> doc = dataStore2.newDocumentTransaction("mydoc")) {
+        doc.setRoot(doc.newSet());
+        doc.commit();
+      }
+      byte[] update2;
+      try (DocumentTransaction<String> doc = dataStore2.newDocumentTransaction("mydoc")) {
+        // Make another commit to advance the version vector.
+        ((SubmergeSet<String>) doc.getRoot()).add("another change");
+        doc.commit();
+        update2 = dataStore2.calculateDelta("mydoc", baseVersion);
+      }
+
+      // 5. Now, apply update from store 2 to store 1. This should fail with a merge exception.
+      assertThrows(MergeException.class, () -> dataStore1.commitNetworkUpdate("mydoc", update2));
+    }
+  }
+
+  @Test
+  public void testMergeNetworkUpdateInTransaction_incompatibleType() throws Exception {
+    // 1. Setup two datastores for the same actor.
+    try (DataStore<String> dataStore1 = createDataStore("actor1");
+         DataStore<String> dataStore2 =
+                    new DataStore<>(
+                            "actor1",
+                            networkInterface,
+                            new Storage(),
+                            timestampProvider,
+                            new StringConverter())) {
+
+      // 2. Create a base document and sync.
+      try (DocumentTransaction<String> doc = dataStore1.newDocumentTransaction("mydoc")) {
+        doc.setRoot(doc.newRegister());
+        doc.commit();
+      }
+      byte[] baseUpdate = dataStore1.getFullUpdateMessage("mydoc");
+      dataStore2.commitNetworkUpdate("mydoc", baseUpdate);
+      VersionVector baseVersion = dataStore1.getDocumentVersion("mydoc");
+
+      // 3. Mock timestamp to create conflicting updates.
+      when(timestampProvider.now()).thenReturn(1000L);
+
+      // 4. Create divergent updates with incompatible types from each actor.
+      try (DocumentTransaction<String> doc = dataStore1.newDocumentTransaction("mydoc")) {
+        doc.setRoot(doc.newMap());
+        doc.commit();
+      }
+
+      try (DocumentTransaction<String> doc = dataStore2.newDocumentTransaction("mydoc")) {
+        doc.setRoot(doc.newSet());
+        doc.commit();
+      }
+      byte[] update2;
+      try (DocumentTransaction<String> doc = dataStore2.newDocumentTransaction("mydoc")) {
+        // Make another commit to advance the version vector.
+        ((SubmergeSet<String>) doc.getRoot()).add("another change");
+        doc.commit();
+        update2 = dataStore2.calculateDelta("mydoc", baseVersion);
+      }
+
+
+      // 5.Now, merge update from store 2 to store 1. This should fail with a merge exception.
+      try (DocumentTransaction<String> doc = dataStore1.newDocumentTransaction("mydoc")) {
+        // Now merge the incompatible update from store 1.
+        assertThrows(MergeException.class, () -> doc.mergeNetworkUpdate(update2));
+      }
+    }
+  }
+
   private static class StringConverter implements Converter<String> {
 
     @Override
diff --git a/betosync/submerge/submerge_java/src/class/data_store.rs b/betosync/submerge/submerge_java/src/class/data_store.rs
index 1aec062..a55ebf9 100644
--- a/betosync/submerge/submerge_java/src/class/data_store.rs
+++ b/betosync/submerge/submerge_java/src/class/data_store.rs
@@ -28,7 +28,8 @@
 use crate::{
     class::{
         exceptions::{
-            older_base_needed_exception, transaction_exception, transaction_exception_with_cause,
+            merge_exception, older_base_needed_exception, transaction_exception,
+            transaction_exception_with_cause,
         },
         storage_interface::StorageError,
     },
@@ -186,6 +187,7 @@
                 let version_handle = VersionVectorHandle::allocate(|| required_base_version)?;
                 Err(older_base_needed_exception(env, version_handle)?)?
             }
+            Err(CommitUpdateError::MergeError(err)) => Err(merge_exception(err.to_string()))?,
             Err(err) => Err(transaction_exception(err.to_string()))?,
         }
         Ok(())
diff --git a/betosync/submerge/submerge_java/src/class/document_transaction.rs b/betosync/submerge/submerge_java/src/class/document_transaction.rs
index 1f2f680..816f1b4 100644
--- a/betosync/submerge/submerge_java/src/class/document_transaction.rs
+++ b/betosync/submerge/submerge_java/src/class/document_transaction.rs
@@ -29,8 +29,8 @@
 use crate::{
     class::{
         exceptions::{
-            older_base_needed_exception, ownership_exception, transaction_exception,
-            transaction_exception_with_cause,
+            merge_exception, older_base_needed_exception, ownership_exception,
+            transaction_exception, transaction_exception_with_cause,
         },
         storage_interface::StorageError,
         submerge_data_type::{CrdtType, JavaSubmergeDataType, SubmergeDataTypeHandle, TreeRoot},
@@ -144,6 +144,7 @@
                     <&JThrowable>::from(throwable.as_obj()),
                 )?)?
             }
+            Err(TransactionError::MergeError(err)) => Err(merge_exception(err.to_string()))?,
             Err(err) => Err(transaction_exception(err.to_string()))?,
         }
         Ok(())
@@ -171,6 +172,7 @@
                 let version_handle = VersionVectorHandle::allocate(|| required_base_version)?;
                 Err(older_base_needed_exception(env, version_handle)?)?
             }
+            Err(CommitUpdateError::MergeError(err)) => Err(merge_exception(err.to_string()))?,
             Err(err) => Err(runtime_exception(err.to_string()))?,
         }
     })
diff --git a/betosync/submerge/submerge_java/src/class/exceptions.rs b/betosync/submerge/submerge_java/src/class/exceptions.rs
index e8ca75a..4f4fd69 100644
--- a/betosync/submerge/submerge_java/src/class/exceptions.rs
+++ b/betosync/submerge/submerge_java/src/class/exceptions.rs
@@ -58,6 +58,13 @@
     }
 }
 
+pub fn merge_exception(msg: impl Into<String>) -> jni::errors::Exception {
+    jni::errors::Exception {
+        class: "com/google/android/submerge/MergeException".into(),
+        msg: msg.into(),
+    }
+}
+
 static TRANSACTION_EXCEPTION_CLASS: ClassDesc =
     ClassDesc::new("com/google/android/submerge/TransactionException");
 
diff --git a/common/Cargo.lock b/common/Cargo.lock
index e328fbb..02ffad3 100644
--- a/common/Cargo.lock
+++ b/common/Cargo.lock
@@ -445,10 +445,14 @@
  "anyhow",
  "chrono",
  "crossbeam",
+ "googletest",
  "mockito",
- "protobuf 3.7.2",
- "protobuf-codegen 3.7.2",
+ "nom",
+ "openssl",
+ "protobuf 4.32.1-release",
+ "protobuf-codegen",
  "protobuf-json-mapping",
+ "protobuf-well-known-types",
  "rand",
  "reqwest",
  "tempfile",
@@ -475,6 +479,16 @@
 ]
 
 [[package]]
+name = "collection_history"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "googletest",
+ "prettytable-rs",
+ "thiserror 2.0.12",
+]
+
+[[package]]
 name = "colorchoice"
 version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -615,6 +629,27 @@
 checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
 
 [[package]]
+name = "csv"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
+dependencies = [
+ "csv-core",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
 name = "directories"
 version = "5.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -624,6 +659,16 @@
 ]
 
 [[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
+[[package]]
 name = "dirs-sys"
 version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -636,6 +681,17 @@
 ]
 
 [[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
 name = "dispatch2"
 version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -663,6 +719,12 @@
 checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
 
 [[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
+[[package]]
 name = "encoding_rs"
 version = "0.8.35"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -981,15 +1043,6 @@
 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
 
 [[package]]
-name = "home"
-version = "0.5.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
-dependencies = [
- "windows-sys 0.59.0",
-]
-
-[[package]]
 name = "http"
 version = "0.2.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1734,9 +1787,9 @@
 
 [[package]]
 name = "openssl"
-version = "0.10.72"
+version = "0.10.73"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
+checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
 dependencies = [
  "bitflags 2.8.0",
  "cfg-if",
@@ -1765,13 +1818,23 @@
 checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
 
 [[package]]
-name = "openssl-sys"
-version = "0.9.107"
+name = "openssl-src"
+version = "300.5.4+3.5.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07"
+checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
 dependencies = [
  "cc",
  "libc",
+ "openssl-src",
  "pkg-config",
  "vcpkg",
 ]
@@ -1835,8 +1898,8 @@
 dependencies = [
  "googletest",
  "ph_client_sys",
- "protobuf 4.32.0-release",
- "protobuf-codegen 4.32.0-release",
+ "protobuf 4.32.1-release",
+ "protobuf-codegen",
  "tokio",
 ]
 
@@ -1980,6 +2043,20 @@
 ]
 
 [[package]]
+name = "prettytable-rs"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a"
+dependencies = [
+ "csv",
+ "encode_unicode",
+ "is-terminal",
+ "lazy_static",
+ "term",
+ "unicode-width",
+]
+
+[[package]]
 name = "proc-macro2"
 version = "1.0.97"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2001,9 +2078,9 @@
 
 [[package]]
 name = "protobuf"
-version = "4.32.0-release"
+version = "4.32.1-release"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "951f19b825af28f7c7ce3daaf71e4e8961394dcc410b3a0aac6e4e0e5b7e2d2b"
+checksum = "72bc0777c4f479a4386e4fc4a04c1ba8fbe9f883bc21268e3073473ad9b07dcc"
 dependencies = [
  "cc",
  "paste",
@@ -2011,24 +2088,9 @@
 
 [[package]]
 name = "protobuf-codegen"
-version = "3.7.2"
+version = "4.32.1-release"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace"
-dependencies = [
- "anyhow",
- "once_cell",
- "protobuf 3.7.2",
- "protobuf-parse",
- "regex",
- "tempfile",
- "thiserror 1.0.69",
-]
-
-[[package]]
-name = "protobuf-codegen"
-version = "4.32.0-release"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7756fa998bcc36bb53ff0d857043d96e734956b8ef46eff17d63f550db629b93"
+checksum = "2e5137852fb606bac9bdba5ef3bb63d77756fdba0378522f6357094cb4e68cae"
 
 [[package]]
 name = "protobuf-json-mapping"
@@ -2042,22 +2104,6 @@
 ]
 
 [[package]]
-name = "protobuf-parse"
-version = "3.7.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973"
-dependencies = [
- "anyhow",
- "indexmap",
- "log",
- "protobuf 3.7.2",
- "protobuf-support",
- "tempfile",
- "thiserror 1.0.69",
- "which",
-]
-
-[[package]]
 name = "protobuf-support"
 version = "3.7.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2067,6 +2113,16 @@
 ]
 
 [[package]]
+name = "protobuf-well-known-types"
+version = "4.32.1-release"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e786f75ec38a6d6c2cef93e0aaf6f039babff7a61bdbfd4da59ff5c4a7f185f9"
+dependencies = [
+ "protobuf 4.32.1-release",
+ "protobuf-codegen",
+]
+
+[[package]]
 name = "quote"
 version = "1.0.40"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2382,18 +2438,28 @@
 
 [[package]]
 name = "serde"
-version = "1.0.217"
+version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
 name = "serde_derive"
-version = "1.0.217"
+version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2601,6 +2667,17 @@
 ]
 
 [[package]]
+name = "term"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f"
+dependencies = [
+ "dirs-next",
+ "rustversion",
+ "winapi",
+]
+
+[[package]]
 name = "thiserror"
 version = "1.0.69"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2804,6 +2881,12 @@
 checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
 
 [[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
 name = "untrusted"
 version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2966,18 +3049,6 @@
 ]
 
 [[package]]
-name = "which"
-version = "4.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
-dependencies = [
- "either",
- "home",
- "once_cell",
- "rustix",
-]
-
-[[package]]
 name = "winapi"
 version = "0.3.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/common/Cargo.toml b/common/Cargo.toml
index 928c82b..ea02608 100644
--- a/common/Cargo.toml
+++ b/common/Cargo.toml
@@ -1,6 +1,7 @@
 [workspace]
 members = [
     "build_scripts",
+    "collection_history",
     "cmd_runner",
     "handle_map",
     "lock_adapter",
@@ -37,6 +38,7 @@
 
 # from crates.io
 anyhow = "1.0.75"
+chrono = "0.4"
 thiserror = "2.0.4"
 arbitrary = "1.3.2"
 clap = { version = "4.5.13", features = ["derive"] }
diff --git a/common/cmd_runner/src/license_checker.rs b/common/cmd_runner/src/license_checker.rs
index 19286bb..101cf1d 100644
--- a/common/cmd_runner/src/license_checker.rs
+++ b/common/cmd_runner/src/license_checker.rs
@@ -80,7 +80,7 @@
                 "Google LLC".to_string(),
             )),
         )? {
-            println!("Added header: {:?}", p);
+            println!("Added header: {p:?}");
         }
 
         Ok(())
diff --git a/common/collection_history/Cargo.toml b/common/collection_history/Cargo.toml
new file mode 100644
index 0000000..6f8d561
--- /dev/null
+++ b/common/collection_history/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "collection_history"
+version.workspace = true
+edition.workspace = true
+publish.workspace = true
+license.workspace = true
+
+[dependencies]
+chrono.workspace = true
+prettytable-rs = "0.10"
+thiserror.workspace = true
+
+[dev-dependencies]
+googletest.workspace = true
diff --git a/common/collection_history/src/lib.rs b/common/collection_history/src/lib.rs
new file mode 100644
index 0000000..a2e61d2
--- /dev/null
+++ b/common/collection_history/src/lib.rs
@@ -0,0 +1,314 @@
+// Copyright 2025 Google LLC
+//
+// 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
+//
+// http://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.
+
+//! A collection that tracks all previous entries that have been inserted and removed and provides a nice-looking output when displayed.
+
+use chrono::{DateTime, Utc};
+use prettytable::{Cell, Row, Table};
+use std::{collections::HashMap, fmt::Debug};
+use thiserror::Error;
+
+/// The insertion for this key-value pair failed.
+#[derive(Error, Debug)]
+#[error("Key {:?} already exists. Cannot insert value {:?}. Key was originally inserted at {}", key, value, insertion_time.to_rfc3339())]
+pub struct InsertError<K: Debug, V: Debug> {
+    pub key: K,
+    pub value: V,
+    insertion_time: DateTime<Utc>,
+}
+
+/// The update operation for this key-value pair failed.
+#[derive(Error, Debug)]
+#[error("Key '{:?}' does not exist. Cannot update value {:?}.", key, value)]
+pub struct UpdateError<K: Debug, V: Debug> {
+    pub key: K,
+    pub value: V,
+}
+
+/// The removal operation for this key failed.
+#[derive(Error, Debug)]
+pub enum RemoveError<K: Debug, V: Debug> {
+    #[error(
+        "Key '{:?}' does not exist. Key was originally removed at time {:?} with value {:?}",
+        key,
+        old_value,
+        removal_time.to_rfc3339()
+    )]
+    KeyAlreadyRemoved {
+        key: K,
+        old_value: V,
+        removal_time: DateTime<Utc>,
+    },
+    #[error("Key '{:?}' does not exist", key)]
+    KeyDoesNotExist { key: K },
+}
+
+/// The get operation for this key failed.
+#[derive(Error, Debug)]
+pub enum GetError<K: Debug, V: Debug> {
+    #[error(
+        "Key '{:?}' does not exist. Key was originally removed at time {:?} with value {:?}",
+        key,
+        old_value,
+        removal_time.to_rfc3339()
+    )]
+    KeyAlreadyRemoved {
+        key: K,
+        old_value: V,
+        removal_time: DateTime<Utc>,
+    },
+    #[error("Key '{:?}' does not exist", key)]
+    KeyDoesNotExist { key: K },
+}
+
+#[derive(Clone, Debug)]
+struct Session<K, V> {
+    key: K,
+    value: V,
+    insertion_time: DateTime<Utc>,
+    last_update_time: DateTime<Utc>,
+    removal_time: Option<DateTime<Utc>>,
+}
+
+impl<K, V> Session<K, V> {
+    fn new(key: K, value: V) -> Self {
+        let now = Utc::now();
+        Self {
+            key,
+            value,
+            insertion_time: now,
+            last_update_time: now,
+            removal_time: None,
+        }
+    }
+
+    fn set_value(&mut self, value: V) -> V {
+        let old_value = std::mem::replace(&mut self.value, value);
+        self.update_last_updated_time();
+        old_value
+    }
+
+    fn update_last_updated_time(&mut self) {
+        self.last_update_time = Utc::now();
+    }
+
+    fn update_removal_time(&mut self) {
+        self.update_last_updated_time();
+        self.removal_time = Some(Utc::now());
+    }
+
+    fn get_duration(&self) -> i64 {
+        let end_time = self.removal_time.unwrap_or_else(Utc::now);
+        end_time.timestamp_millis() - self.insertion_time.timestamp_millis()
+    }
+}
+
+enum EventStatus {
+    Active,
+    Complete,
+}
+
+impl std::fmt::Display for EventStatus {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            EventStatus::Active => write!(f, "ACTIVE"),
+            EventStatus::Complete => write!(f, "COMPLETE"),
+        }
+    }
+}
+
+/// A map that tracks when keys were added and removed.
+#[derive(Clone)]
+pub struct CollectionHistory<K, V> {
+    max_history: usize,
+    past_sessions: Vec<Session<K, V>>,
+    active_sessions: HashMap<K, Session<K, V>>,
+}
+
+impl<K, V> CollectionHistory<K, V>
+where
+    K: Eq + std::hash::Hash + Clone + std::fmt::Debug,
+    V: std::fmt::Debug + Clone,
+{
+    /// Creates a new [CollectionHistory] with a maximum number of `max_history` historical entries tracked.
+    pub fn new(max_history: usize) -> Self {
+        Self {
+            max_history,
+            past_sessions: Vec::new(),
+            active_sessions: HashMap::new(),
+        }
+    }
+
+    /// Inserts a key-value pair. If the key already exists, an InsertError with the key and value is returned.
+    pub fn insert(&mut self, key: K, value: V) -> Result<(), InsertError<K, V>> {
+        if let Some(session) = self.active_sessions.get(&key) {
+            return Err(InsertError {
+                key,
+                value,
+                insertion_time: session.insertion_time,
+            });
+        }
+
+        self.active_sessions
+            .insert(key.clone(), Session::new(key, value));
+        self.prune_old_sessions();
+        Ok(())
+    }
+
+    /// Updates the key to the new value, returning the old value if successful.
+    pub fn update(&mut self, key: K, value: V) -> Result<V, UpdateError<K, V>> {
+        if let Some(session) = self.active_sessions.get_mut(&key) {
+            return Ok(session.set_value(value));
+        }
+        Err(UpdateError { key, value })
+    }
+
+    /// Removes the value corresponding to key, if it exists.
+    pub fn remove(&mut self, key: K) -> Result<V, RemoveError<K, V>> {
+        if let Some(mut session) = self.active_sessions.remove(&key) {
+            session.update_removal_time();
+            let value = session.value.clone();
+            self.past_sessions.insert(0, session);
+            self.prune_old_sessions();
+            Ok(value)
+        } else if let Some(session) = self.find_old_session(&key) {
+            return Err(RemoveError::KeyAlreadyRemoved {
+                key,
+                old_value: session.value.clone(),
+                removal_time: session.last_update_time,
+            });
+        } else {
+            return Err(RemoveError::KeyDoesNotExist { key });
+        }
+    }
+
+    /// Returns true if key is currently active.
+    pub fn contains_key(&self, key: &K) -> bool {
+        self.active_sessions.contains_key(key)
+    }
+
+    /// Returns the value corresponding to `key` if it exists.
+    pub fn get(&self, key: &K) -> Result<&V, GetError<K, &V>> {
+        if let Some(session) = self.active_sessions.get(key) {
+            Ok(&session.value)
+        } else if let Some(session) = self.find_old_session(key) {
+            Err(GetError::KeyAlreadyRemoved {
+                key: key.clone(),
+                old_value: &session.value,
+                removal_time: session.last_update_time,
+            })
+        } else {
+            Err(GetError::KeyDoesNotExist { key: key.clone() })
+        }
+    }
+
+    /// Clears this [CollectionHistory].
+    pub fn clear(&mut self) {
+        let keys: Vec<K> = self.active_sessions.keys().cloned().collect();
+        for key in keys {
+            let _ = self.remove(key);
+        }
+    }
+
+    /// Returns an iterator over the active keys in this [CollectionHistory].
+    pub fn keys(&self) -> impl Iterator<Item = &K> {
+        self.active_sessions.keys()
+    }
+
+    /// Returns an iterator over the active values in this [CollectionHistory].
+    pub fn values(&self) -> impl Iterator<Item = &V> {
+        self.active_sessions.values().map(|s| &s.value)
+    }
+
+    /// Returns an iterator over the active entries in this [CollectionHistory].
+    pub fn entry_set(&self) -> impl Iterator<Item = (&K, &V)> {
+        self.active_sessions.iter().map(|(k, s)| (k, &s.value))
+    }
+
+    /// Returns the number of entries in this [CollectionHistory].
+    pub fn len(&self) -> usize {
+        self.active_sessions.len()
+    }
+
+    /// Returns true if there are no active sessions in this [CollectionHistory].
+    pub fn is_empty(&self) -> bool {
+        self.active_sessions.is_empty()
+    }
+
+    fn history_size(&self) -> usize {
+        self.past_sessions.len() + self.len()
+    }
+
+    fn prune_old_sessions(&mut self) {
+        if self.active_sessions.len() >= self.max_history {
+            self.past_sessions.clear();
+            return;
+        }
+
+        while self.history_size() > self.max_history {
+            self.past_sessions.remove(0);
+        }
+    }
+
+    fn find_old_session(&self, key: &K) -> Option<&Session<K, V>> {
+        self.past_sessions.iter().find(|s| &s.key == key)
+    }
+}
+
+impl<K: Debug, V: Debug> std::fmt::Display for CollectionHistory<K, V> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let mut sorted_sessions: Vec<&Session<K, V>> = self.active_sessions.values().collect();
+        sorted_sessions.sort_by(|a, b| b.last_update_time.cmp(&a.last_update_time));
+
+        let mut table = Table::new();
+        table.add_row(Row::new(vec![
+            Cell::new("Key"),
+            Cell::new("Value"),
+            Cell::new("Status"),
+            Cell::new("Start Time"),
+            Cell::new("End Time"),
+            Cell::new("Duration"),
+        ]));
+
+        for session in sorted_sessions {
+            table.add_row(Row::new(vec![
+                Cell::new(&format!("{:?}", &session.key)),
+                Cell::new(&format!("{:?}", &session.value)),
+                Cell::new(&EventStatus::Active.to_string()),
+                Cell::new(&session.insertion_time.to_rfc3339()),
+                Cell::new(&session.last_update_time.to_rfc3339()),
+                Cell::new(&format!("{}ms", session.get_duration())),
+            ]));
+        }
+
+        for session in &self.past_sessions {
+            table.add_row(Row::new(vec![
+                Cell::new(&format!("{:?}", &session.key)),
+                Cell::new(&format!("{:?}", &session.value)),
+                Cell::new(&EventStatus::Complete.to_string()),
+                Cell::new(&session.insertion_time.to_rfc3339()),
+                Cell::new(&session.last_update_time.to_rfc3339()),
+                Cell::new(&format!("{}ms", session.get_duration())),
+            ]));
+        }
+
+        write!(f, "{table}")
+    }
+}
diff --git a/common/collection_history/tests/tests.rs b/common/collection_history/tests/tests.rs
new file mode 100644
index 0000000..2b1cd50
--- /dev/null
+++ b/common/collection_history/tests/tests.rs
@@ -0,0 +1,97 @@
+// Copyright 2025 Google LLC
+//
+// 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
+//
+// http://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 collection_history::CollectionHistory;
+use googletest::prelude::*;
+
+#[gtest]
+fn insert_remove_update_fails() {
+    let mut map = CollectionHistory::<u64, String>::new(10);
+
+    assert!(map.insert(1, "Hello".to_string()).is_ok());
+    assert!(map.remove(1).is_ok());
+    assert!(map.update(1, "Bye".to_string()).is_err());
+    assert!(map.is_empty())
+}
+
+#[gtest]
+fn insert_remove_insert() {
+    let mut map = CollectionHistory::<u64, String>::new(10);
+
+    assert!(map.insert(1, "Hello".to_string()).is_ok());
+    assert!(map.remove(1).is_ok());
+    assert!(map.insert(1, "Bye".to_string()).is_ok());
+    assert_that!(
+        map.entry_set()
+            .map(|(k, v)| (*k, v.clone()))
+            .collect::<Vec<(u64, String)>>(),
+        contains(eq(&(1, "Bye".to_string())))
+    )
+}
+
+#[gtest]
+fn remove_remove_fails() {
+    let mut map = CollectionHistory::<u64, String>::new(10);
+
+    assert!(map.insert(1, "Hello".to_string()).is_ok());
+    assert!(map.remove(1).is_ok());
+    assert!(map.remove(1).is_err());
+    assert!(map.is_empty())
+}
+
+#[gtest]
+fn clear() {
+    let mut map = CollectionHistory::<u64, String>::new(10);
+
+    assert!(map.insert(1, "Hello".to_string()).is_ok());
+    map.clear();
+    assert_that!(map.len(), eq(0));
+}
+
+#[gtest]
+fn history_limit_does_not_apply_to_active() {
+    let mut map = CollectionHistory::<u64, String>::new(1);
+    assert!(map.insert(1, "Hello".to_string()).is_ok());
+    assert!(map.insert(2, "Bye".to_string()).is_ok());
+
+    assert_that!(map.len(), eq(2));
+}
+
+#[gtest]
+fn update_returns_old_value() {
+    let mut map = CollectionHistory::<u64, String>::new(1);
+    assert!(map.insert(1, "Hello".to_string()).is_ok());
+    assert_that!(
+        map.update(1, "Bye".to_string()),
+        matches_pattern!(Ok("Hello"))
+    );
+}
+
+/*
+#[gtest]
+fn print_format() -> Result<()> {
+    let mut map = CollectionHistory::<u64, String>::new(1);
+    assert!(map.insert(1, "Hello".to_string()).is_ok());
+    assert!(map.insert(2, "Bye".to_string()).is_ok());
+
+    eprintln!("{}", map);
+    fail!()
+}
+*/
diff --git a/common/cooperative_cleaner/lib/build.gradle.kts b/common/cooperative_cleaner/lib/build.gradle.kts
index dba0308..5232e35 100644
--- a/common/cooperative_cleaner/lib/build.gradle.kts
+++ b/common/cooperative_cleaner/lib/build.gradle.kts
@@ -14,44 +14,45 @@
  * limitations under the License.
  */
 
-import net.ltgt.gradle.errorprone.errorprone;
+import net.ltgt.gradle.errorprone.errorprone
 
 plugins {
-  `java-library`
-  id("net.ltgt.errorprone") version "4.0.0"
+    `java-library`
+    id("net.ltgt.errorprone") version "4.3.0"
 }
 
 java {
-  // Gradle JUnit test finder doesn't support Java 21 class files.
-  sourceCompatibility = JavaVersion.VERSION_1_9
-  targetCompatibility = JavaVersion.VERSION_1_9
+    // Gradle JUnit test finder doesn't support Java 21 class files.
+    sourceCompatibility = JavaVersion.VERSION_1_9
+    targetCompatibility = JavaVersion.VERSION_1_9
 }
 
 repositories {
-  mavenCentral()
-  google()
+    mavenCentral()
+    google()
 }
 
 dependencies {
-  compileOnly("androidx.annotation:annotation:1.6.0")
-  implementation("com.google.errorprone:error_prone_core:2.28.0")
+    compileOnly("androidx.annotation:annotation:1.6.0")
+    errorprone("com.google.errorprone:error_prone_core:2.41.0")
+    implementation("org.checkerframework:checker-qual:3.51.0")
 
-  testImplementation("junit:junit:4.13")
-  testImplementation("com.google.truth:truth:1.1.4")
-  testImplementation("org.mockito:mockito-core:5.+")
+    testImplementation("junit:junit:4.13")
+    testImplementation("com.google.truth:truth:1.1.4")
+    testImplementation("org.mockito:mockito-core:5.+")
 }
 
 tasks.withType<JavaCompile>().configureEach {
-  options.errorprone {
-    error("CheckReturnValue")
-    error("UnnecessaryStaticImport")
-    error("WildcardImport")
-    error("RemoveUnusedImports")
-    error("ReturnMissingNullable")
-    error("FieldMissingNullable")
-    error("AnnotationPosition")
-    error("CheckedExceptionNotThrown")
-    error("NonFinalStaticField")
-    error("InvalidLink")
-  }
+    options.errorprone {
+        error("CheckReturnValue")
+        error("UnnecessaryStaticImport")
+        error("WildcardImport")
+        error("RemoveUnusedImports")
+        error("ReturnMissingNullable")
+        error("FieldMissingNullable")
+        error("AnnotationPosition")
+        error("CheckedExceptionNotThrown")
+        error("NonFinalStaticField")
+        error("InvalidLink")
+    }
 }
diff --git a/common/rust-toolchain.toml b/common/rust-toolchain.toml
index 0193dee..e88baf1 100644
--- a/common/rust-toolchain.toml
+++ b/common/rust-toolchain.toml
@@ -1,2 +1,2 @@
 [toolchain]
-channel = "1.83.0"
+channel = "1.88.0"
diff --git a/nearby/.gitignore b/nearby/.gitignore
index 526b951..a633d22 100644
--- a/nearby/.gitignore
+++ b/nearby/.gitignore
@@ -7,3 +7,4 @@
 build/
 project.xcworkspace/
 xcuserdata/
+.gemini/
diff --git a/nearby/CMakeLists.txt b/nearby/CMakeLists.txt
index d493688..6544d73 100644
--- a/nearby/CMakeLists.txt
+++ b/nearby/CMakeLists.txt
@@ -22,6 +22,9 @@
 set(NEARBY_ROOT ${CMAKE_SOURCE_DIR})
 set(THIRD_PARTY_DIR ${BETO_CORE_ROOT}/third_party)
 
+set(CMAKE_C_COMPILER ${CC})
+set(CMAKE_CXX_COMPILER ${CXX})
+
 set(CMAKE_C_FLAGS_DEBUG "-DDEBUG")
 set(CMAKE_CXX_FLAGS_DEBUG "-DDEBUG")
 if (UNIX)
diff --git a/nearby/Cargo.toml b/nearby/Cargo.toml
index 2b1bab1..b416e46 100644
--- a/nearby/Cargo.toml
+++ b/nearby/Cargo.toml
@@ -8,6 +8,8 @@
     "connections/ukey2/ukey2_jni",
     "connections/ukey2/ukey2_proto",
     "connections/ukey2/ukey2_shell",
+    "connections/nc_wire_proto",
+    "connections/nc_multiplex_proto",
     "crypto/crypto_provider",
     "crypto/crypto_provider_rustcrypto",
     "crypto/crypto_provider_stubs",
@@ -36,6 +38,7 @@
     "presence/test_vector_hkdf",
     "presence/xts_aes",
     "presence/xts_aes/fuzz",
+    "unified_protocol/up_adv",
 ]
 default-members = ["build_scripts"]
 
@@ -66,6 +69,8 @@
 
 [workspace.dependencies]
 # local crates
+connections_adv = { path = "connections/connections_adv/connections_adv" }
+mediums_adv = { path = "connections/connections_adv/mediums_adv" }
 array_ref = { path = "presence/array_ref" }
 array_view = { path = "presence/array_view" }
 ble = { path = "medium/ble" }
@@ -82,7 +87,11 @@
 crypto_provider_stubs = { path = "crypto/crypto_provider_stubs" }
 crypto_provider_test = { path = "crypto/crypto_provider_test" }
 device_info = { path = "device_info" }
+event_loop = { path = "presence/event_loop" }
+np_discovery = { path = "presence/engine/presence_rust" }
 nc = { path = "connections/nc" }
+nc_wire_proto = { path = "connections/nc_wire_proto" }
+nc_multiplex_proto = {path = "connections/nc_multiplex_proto"}
 rand_ext = { path = "presence/rand_ext" }
 test_helper = { path = "presence/test_helper" }
 ukey2_connections = { path = "connections/ukey2/ukey2_connections" }
@@ -94,6 +103,7 @@
 ldt_np_adv = { path = "presence/ldt_np_adv" }
 ldt_tbc = { path = "presence/ldt_tbc" }
 np_adv = { path = "presence/np_adv" }
+up_adv = { path = "unified_protocol/up_adv" }
 np_adv_dynamic = { path = "presence/np_adv_dynamic" }
 np_ffi_core = { path = "presence/np_ffi_core", default-features = false }
 np_ffi_core_fuzz = { path = "presence/np_ffi_core/fuzz" }
@@ -113,6 +123,10 @@
 ph_flag_source = { path = "../common/1p_internal/ph_flag_source", default-features = false }
 ph_client = { path = "../common/1p_internal/ph_client" }
 
+# analytics
+analytics_logger = { path = "../common/1p_internal/clearcut/analytics_logger" }
+clearcut_logger = { path = "../common/1p_internal/clearcut/internal/clearcut_logger" }
+
 # from crates.io
 rand = { version = "0.8.5", default-features = false }
 rand_core = { version = "0.6.4", features = ["getrandom"] }
@@ -151,6 +165,7 @@
 anyhow = "1.0.75"
 log = "0.4.20"
 env_logger = "0.11.7"
+gag = "1.0.0"
 oslog = "0.2.0"
 criterion = { version = "0.5.1", features = ["html_reports"] }
 clap = { version = "4.5.13", features = ["derive"] }
@@ -161,7 +176,8 @@
 hashbrown = "0.15"
 hdrhistogram = "7.5.4"
 regex = "1.10.2"
-tokio = { version = "1.35.0", features = [
+crossbeam = "0.8.4"
+tokio = { version = "1.47.1", features = [
     "macros",
     "rt",
     "sync",
@@ -169,8 +185,10 @@
     "time",
 ] }
 tokio-stream = { version = "0.1.17" }
+tokio-util = "0.7.16"
 xts-mode = "0.5.1"
 rstest = { version = "0.18.2", default-features = false }
+serial_test = "3.2.0"
 rstest_reuse = "0.6.0"
 wycheproof = "0.5.1"
 chrono = { version = "0.4.31", default-features = false, features = ["clock"] }
@@ -194,11 +212,11 @@
 libfuzzer-sys = "0.4.7"
 derive_fuzztest = "0.1.4"
 derive_fuzztest_macro = "0.1.4"
-async-trait = "0.1.83"
+async-trait = "0.1.89"
 directories = "5.0.1"
 uuid = { version = "1.11.0", features = ["v4"] }
 mockall = "0.13.1"
-uniffi = "0.29.3"
+uniffi = "0.29.0"
 async-stream = "0.3.6"
 prost = "0.14.1"
 ascii85 = "0.2.1"
@@ -217,6 +235,7 @@
     "Win32_System_RemoteDesktop",
 ] }
 windows-core = "0.61.0"
+googletest = "0.14.2"
 
 dispatch2 = { version = "0.3.0" }
 objc2 = { version = "0.6.1" }
@@ -232,6 +251,8 @@
     "NSValue",
 ] }
 objc2-core-bluetooth = { version = "0.3.1" }
+android_logger = "0.15.1"
+num_enum = "0.7.5"
 
 [workspace.package]
 version = "0.1.0"
diff --git a/nearby/build_scripts/Cargo.toml b/nearby/build_scripts/Cargo.toml
index 72c41d0..2aeaec2 100644
--- a/nearby/build_scripts/Cargo.toml
+++ b/nearby/build_scripts/Cargo.toml
@@ -25,6 +25,7 @@
 serde_json = { workspace = true, features = ["std"] }
 regex = "1.10.2"
 xshell = "0.2.6"
+protoc = "2.28.0"
 
 [dev-dependencies]
 env_logger.workspace = true
diff --git a/nearby/build_scripts/src/crypto_ffi.rs b/nearby/build_scripts/src/crypto_ffi.rs
index 6ef5350..3676a23 100644
--- a/nearby/build_scripts/src/crypto_ffi.rs
+++ b/nearby/build_scripts/src/crypto_ffi.rs
@@ -53,8 +53,7 @@
     run_cmd_shell_with_color::<YellowStderr>(
         &build_dir,
         format!(
-            "cmake -G Ninja .. -DRUST_BINDINGS={} -DCMAKE_POSITION_INDEPENDENT_CODE=true",
-            target
+            "cmake -G Ninja .. -DRUST_BINDINGS={target} -DCMAKE_POSITION_INDEPENDENT_CODE=true"
         ),
     )?;
     run_cmd_shell(&build_dir, "ninja")?;
diff --git a/nearby/build_scripts/src/ffi.rs b/nearby/build_scripts/src/ffi.rs
index 4bc2513..721fab8 100644
--- a/nearby/build_scripts/src/ffi.rs
+++ b/nearby/build_scripts/src/ffi.rs
@@ -25,6 +25,8 @@
     check_np_ffi_cmake(root, cargo_options, boringssl_enabled)?;
     check_ldt_cmake(root, cargo_options, boringssl_enabled)?;
     check_ukey2_cmake(root, cargo_options, boringssl_enabled)?;
+    #[cfg(not(target_os = "linux"))]
+    check_quick_share_ffi(root, cargo_options)?;
     Ok(())
 }
 
@@ -120,6 +122,32 @@
     Ok(())
 }
 
+pub(crate) fn check_quick_share_ffi(
+    root: &path::Path,
+    cargo_options: &CargoOptions,
+) -> anyhow::Result<()> {
+    log::info!("Checking that the Quick Share FFI crate builds for various platforms");
+
+    run_cmd_shell(root, "cargo build -p quick_share_ffi --quiet")?;
+
+    for platform in &cargo_options.platforms {
+        let features = if platform.contains("ios") {
+            vec!["--features quick_share_service/short_fast_init"]
+        } else {
+            vec![]
+        };
+        run_cmd_shell(
+            root,
+            format!(
+                "cargo build -p quick_share_ffi --target {platform} --quiet {}",
+                &features.join(" ")
+            ),
+        )?;
+    }
+
+    Ok(())
+}
+
 pub(crate) fn generate_test_data(root: &path::Path) -> anyhow::Result<()> {
     run_cmd_shell(root, "cargo run -p np_adv -Falloc,devtools --example generate_test_data")?;
     Ok(())
diff --git a/nearby/build_scripts/src/fuzzers.rs b/nearby/build_scripts/src/fuzzers.rs
index c2caf29..84d4056 100644
--- a/nearby/build_scripts/src/fuzzers.rs
+++ b/nearby/build_scripts/src/fuzzers.rs
@@ -40,7 +40,7 @@
     for target in ["deserialization_fuzzer", "ldt_fuzzer"] {
         run_cmd_shell_with_color::<YellowStderr>(
             &build_dir,
-            format!("cmake --build . --target {}", target),
+            format!("cmake --build . --target {target}"),
         )?;
     }
 
diff --git a/nearby/build_scripts/src/jni.rs b/nearby/build_scripts/src/jni.rs
index 75cf1ff..1002ff5 100644
--- a/nearby/build_scripts/src/jni.rs
+++ b/nearby/build_scripts/src/jni.rs
@@ -32,6 +32,9 @@
 pub fn run_np_java_ffi_tests(root: &path::Path) -> anyhow::Result<()> {
     run_cmd_shell(root, "cargo build -p np_java_ffi -F np_ffi_core/rustcrypto -F testing")?;
     let np_java_path = root.to_path_buf().join("presence/np_java_ffi");
+    // This needs to run once a while to upgrade the gradle to the latest.
+    // Particularly, when these tests fail, uncomment and upgrade.
+    // run_cmd_shell(&np_java_path, "./gradlew wrapper --gradle-version latest")?;
     run_cmd_shell(&np_java_path, "./gradlew :test --info --rerun")?;
     run_cmd_shell(&np_java_path, "./gradlew :proguardedTest --info --rerun")?;
     Ok(())
diff --git a/nearby/build_scripts/src/license.rs b/nearby/build_scripts/src/license.rs
index a566cb3..9723d60 100644
--- a/nearby/build_scripts/src/license.rs
+++ b/nearby/build_scripts/src/license.rs
@@ -63,6 +63,7 @@
         "**/*.so",
         "**/*.dylib",
         "**/MANIFEST.MF",
+        "**/*.pro",
     ],
 };
 
diff --git a/nearby/build_scripts/src/main.rs b/nearby/build_scripts/src/main.rs
index f81cc6c..6048cc0 100644
--- a/nearby/build_scripts/src/main.rs
+++ b/nearby/build_scripts/src/main.rs
@@ -68,6 +68,9 @@
             license_subcommand.run(&LICENSE_CHECKER, root_dir)?
         }
         Subcommand::CheckUkey2Ffi(ref options) => ffi::check_ukey2_cmake(root_dir, options, false)?,
+        Subcommand::CheckQuickShareFfi(ref options) => {
+            ffi::check_quick_share_ffi(root_dir, options)?
+        }
         Subcommand::RunUkey2JniTests => jni::run_ukey2_jni_tests(root_dir)?,
         Subcommand::RunNpJavaFfiTests => jni::run_np_java_ffi_tests(root_dir)?,
         Subcommand::RunDctJavaFfiTests => jni::run_dct_java_ffi_tests(root_dir)?,
@@ -124,7 +127,7 @@
 
         let root_str =
             glob::Pattern::escape(root.to_str().expect("Non-unicode paths are not supported"));
-        let search = format!("{}/**/*.java", root_str);
+        let search = format!("{root_str}/**/*.java");
         let java_files: Vec<_> = glob::glob(&search)
             .unwrap()
             .filter_map(Result::ok)
@@ -158,13 +161,17 @@
 
     for cargo_cmd in [
         // make sure everything compiles
-        &format!("cargo check --workspace --all-targets --quiet --features={}", workspace_features.join(",")),
+        &format!(
+            "cargo check --workspace --all-targets --quiet --features={}",
+            workspace_features.join(",")
+        ),
         "cargo check -p np_adv -Falloc,devtools --example generate_test_data",
+        "cargo check -p up_adv -Falloc,devtools --example generate_test_data",
         // run all the tests
         &options.cargo_options.test(
-            "check_workspace", 
+            "check_workspace",
             format!("--workspace --quiet --features={}", workspace_features.join(",")),
-            None
+            None,
         ),
         // Test ukey2 builds with different crypto providers
         &options.cargo_options.test(
@@ -305,6 +312,8 @@
     License(LicenseSubcommand),
     /// Builds and runs tests for the UKEY2 FFI
     CheckUkey2Ffi(CargoOptions),
+    /// Builds the Quick Share FFI crate.
+    CheckQuickShareFfi(CargoOptions),
     /// Runs the kotlin tests of the LDT Jni API
     RunLdtKotlinTests,
     /// Checks the build of the ukey2_jni wrapper and runs tests
@@ -339,6 +348,11 @@
     locked: bool,
     #[arg(long, help = "gather coverage metrics")]
     coverage: bool,
+    #[arg(
+        long,
+        help = "platforms to run on, other than the default. platforms specified here should have already been installed via rustup."
+    )]
+    platforms: Vec<String>,
 }
 
 #[derive(PartialEq, Eq)]
diff --git a/nearby/connections/ukey2/ukey2_c_ffi/src/lib.rs b/nearby/connections/ukey2/ukey2_c_ffi/src/lib.rs
index be24c66..a44b2f6 100644
--- a/nearby/connections/ukey2/ukey2_c_ffi/src/lib.rs
+++ b/nearby/connections/ukey2/ukey2_c_ffi/src/lib.rs
@@ -102,14 +102,14 @@
 #[no_mangle]
 pub unsafe extern "C" fn rust_dealloc_ffi_byte_array(arr: RustFFIByteArray) {
     if let Some(vec) = arr.into_vec() {
-        core::mem::drop(vec);
+        drop(vec);
     }
 }
 
 // Common functions
 #[no_mangle]
 pub extern "C" fn is_handshake_complete(handle: u64) -> bool {
-    HANDLE_MAPPING.lock().get(&handle).map_or(false, |ctx| ctx.is_handshake_complete())
+    HANDLE_MAPPING.lock().get(&handle).is_some_and(|ctx| ctx.is_handshake_complete())
 }
 
 #[no_mangle]
@@ -135,7 +135,7 @@
     if let Err(error) = result {
         match error {
             HandleMessageError::InvalidState | HandleMessageError::BadMessage => {
-                log::error!("{:?}", error);
+                log::error!("{error:?}");
             }
             HandleMessageError::ErrorMessage(message) => {
                 return CMessageParseResult {
diff --git a/nearby/connections/ukey2/ukey2_connections/src/d2d_connection_context_v1.rs b/nearby/connections/ukey2/ukey2_connections/src/d2d_connection_context_v1.rs
index 99832f3..25aa05f 100644
--- a/nearby/connections/ukey2/ukey2_connections/src/d2d_connection_context_v1.rs
+++ b/nearby/connections/ukey2/ukey2_connections/src/d2d_connection_context_v1.rs
@@ -303,9 +303,9 @@
     ///
     /// * `payload` - The payload that should be encrypted.
     /// * `associated_data` - Optional data that is not included in the payload but is included in
-    ///       the calculation of the signature for this message. Note that the *size* (length in
-    ///       bytes) of the associated data will be sent in the *UNENCRYPTED* header information,
-    ///       even if you are using encryption.
+    ///   the calculation of the signature for this message. Note that the *size* (length in bytes)
+    ///   of the associated data will be sent in the *UNENCRYPTED* header information, even if you
+    ///   are using encryption.
     pub fn encode_message_to_peer<C: CryptoProvider>(
         &mut self,
         payload: &[u8],
@@ -320,7 +320,7 @@
     ///
     /// * `message` - the message that should be encrypted.
     /// * `associated_data` - Optional associated data that must match what the sender provided. See
-    ///       the documentation on [`encode_message_to_peer`][Self::encode_message_to_peer].
+    ///   the documentation on [`encode_message_to_peer`][Self::encode_message_to_peer].
     pub fn decode_message_from_peer<C: CryptoProvider>(
         &mut self,
         message: &[u8],
diff --git a/nearby/crypto/crypto_provider_test/src/ecdsa.rs b/nearby/crypto/crypto_provider_test/src/ecdsa.rs
index 265a889..a00c628 100644
--- a/nearby/crypto/crypto_provider_test/src/ecdsa.rs
+++ b/nearby/crypto/crypto_provider_test/src/ecdsa.rs
@@ -47,11 +47,10 @@
                 if let Err(desc) = result {
                     panic!(
                         "\n\
-                         Failed test {}: {}\n\
-                         msg:\t{:?}\n\
-                         sig:\t{:?}\n\
-                         comment:\t{:?}\n",
-                        tc_id, desc, msg, sig, comment,
+                         Failed test {tc_id}: {desc}\n\
+                         msg:\t{msg:?}\n\
+                         sig:\t{sig:?}\n\
+                         comment:\t{comment:?}\n",
                     );
                 }
             } else {
diff --git a/nearby/crypto/crypto_provider_test/src/ed25519.rs b/nearby/crypto/crypto_provider_test/src/ed25519.rs
index f2bc7ef..d005c58 100644
--- a/nearby/crypto/crypto_provider_test/src/ed25519.rs
+++ b/nearby/crypto/crypto_provider_test/src/ed25519.rs
@@ -54,11 +54,10 @@
                 if let Err(desc) = result {
                     panic!(
                         "\n\
-                         Failed test {}: {}\n\
-                         msg:\t{:?}\n\
-                         sig:\t{:?}\n\
-                         comment:\t{:?}\n",
-                        tc_id, desc, msg, sig, comment,
+                         Failed test {tc_id}: {desc}\n\
+                         msg:\t{msg:?}\n\
+                         sig:\t{sig:?}\n\
+                         comment:\t{comment:?}\n",
                     );
                 }
             } else {
@@ -107,10 +106,9 @@
         if let Err(desc) = result {
             panic!(
                 "\n\
-                         Failed test {}: {}\n\
-                         msg:\t{:?}\n\
-                         sig:\t{:?}\n\"",
-                tc_id, desc, msg, sig,
+                         Failed test {tc_id}: {desc}\n\
+                         msg:\t{msg:?}\n\
+                         sig:\t{sig:?}\n\"",
             );
         }
     }
diff --git a/nearby/crypto/crypto_provider_test/src/sha2.rs b/nearby/crypto/crypto_provider_test/src/sha2.rs
index b10ac9a..2d68d5b 100644
--- a/nearby/crypto/crypto_provider_test/src/sha2.rs
+++ b/nearby/crypto/crypto_provider_test/src/sha2.rs
@@ -76,7 +76,7 @@
             } else if let Some(hex_str) = line.strip_prefix("MD = ") {
                 md = Some(Vec::<u8>::from_hex(hex_str).unwrap());
             } else {
-                panic!("Unexpected line in test file: `{}`", line);
+                panic!("Unexpected line in test file: `{line}`");
             }
         }
         if let (Some(len), Some(msg), Some(md)) = (len, msg, md) {
diff --git a/nearby/deny.toml b/nearby/deny.toml
index 20f50aa..83a95b2 100644
--- a/nearby/deny.toml
+++ b/nearby/deny.toml
@@ -66,6 +66,7 @@
     "ISC",
     "MPL-2.0",
     "Zlib",
+    "OpenSSL",
 ]
 # The confidence threshold for detecting a license from license text.
 # The higher the value, the more closely the license text must be to the
diff --git a/nearby/presence/array_view/src/lib.rs b/nearby/presence/array_view/src/lib.rs
index d4d7c84..9edf43e 100644
--- a/nearby/presence/array_view/src/lib.rs
+++ b/nearby/presence/array_view/src/lib.rs
@@ -43,6 +43,7 @@
     }
     /// A version of [`ArrayView#try_from_array`] which panics if `len > buffer.len()`,
     /// suitable for usage in `const` contexts.
+    #[allow(clippy::panic)]
     pub const fn const_from_array(array: [T; N], len: usize) -> ArrayView<T, N> {
         if N < len {
             panic!("Invalid const ArrayView");
diff --git a/nearby/presence/ldt_np_adv/src/tests.rs b/nearby/presence/ldt_np_adv/src/tests.rs
index a731762..fabd74c 100644
--- a/nearby/presence/ldt_np_adv/src/tests.rs
+++ b/nearby/presence/ldt_np_adv/src/tests.rs
@@ -274,7 +274,7 @@
     fn legacy_identity_token() {
         let token = V0IdentityToken::from([0; V0_IDENTITY_TOKEN_LEN]);
         // debug
-        let _ = format!("{:?}", token);
+        let _ = format!("{token:?}");
         // hash
         let _ = collections::HashSet::new().insert(&token);
         // bytes
diff --git a/nearby/presence/np_adv/src/credential/book.rs b/nearby/presence/np_adv/src/credential/book.rs
index 7ab3b5f..142bb28 100644
--- a/nearby/presence/np_adv/src/credential/book.rs
+++ b/nearby/presence/np_adv/src/credential/book.rs
@@ -109,6 +109,22 @@
         let v1_source = PrecalculatedOwnedCredentialSource::<V1, M>::new::<P>(v1_iter);
         CredentialBookFromSources::new(v0_source, v1_source)
     }
+
+    #[cfg(feature = "alloc")]
+    /// Constructs a new credential-book which owns all of the given credentials,
+    /// and maintains pre-calculated cryptographic information about them
+    /// for speedy advertisement deserialization.
+    pub fn build_precalculated_owned_book_with_id<P: CryptoProvider>(
+        v0_iter: impl IntoIterator<Item = MatchableCredential<V0, M>>,
+        v1_iter: impl IntoIterator<Item = MatchableCredential<V1, M>>,
+    ) -> PrecalculatedOwnedCredentialBookWithId<M>
+    where
+        M: MatchedCredential + Clone,
+    {
+        let v0_source = PrecalculatedOwnedV0CredentialSourceWithId::<M>::new::<P>(v0_iter);
+        let v1_source = PrecalculatedOwnedV1CredentialSourceWithId::<M>::new::<P>(v1_iter);
+        CredentialBookFromSources::new(v0_source, v1_source)
+    }
 }
 
 // Now, for the implementation details. External implementors still need
@@ -545,3 +561,172 @@
     PrecalculatedOwnedCredentialSource<V0, M>,
     PrecalculatedOwnedCredentialSource<V1, M>,
 >;
+
+#[cfg(any(feature = "alloc", test))]
+/// A credential-book which owns all of its (non-matched) credential data,
+/// and maintains pre-calculated cryptographic information about all
+/// stored credentials for speedy advertisement deserialization.
+pub type PrecalculatedOwnedCredentialBookWithId<M> = CredentialBookFromSources<
+    PrecalculatedOwnedV0CredentialSourceWithId<M>,
+    PrecalculatedOwnedV1CredentialSourceWithId<M>,
+>;
+
+#[cfg(any(feature = "alloc", test))]
+/// A simple credentials source that holds owned, pre-calculated V0 crypto-materials.
+pub struct PrecalculatedOwnedV0CredentialSourceWithId<M: MatchedCredential> {
+    credentials:
+        Vec<
+            (
+                <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+                    V0,
+                >>::Output,
+                M,
+            ),
+        >,
+}
+
+#[cfg(any(feature = "alloc", test))]
+impl<M: MatchedCredential + Clone> PrecalculatedOwnedV0CredentialSourceWithId<M> {
+    /// Pre-calculates crypto material for the given credentials and constructs a
+    /// new source which owns this crypto-material.
+    pub fn new<P: CryptoProvider>(
+        credential_iter: impl IntoIterator<Item = MatchableCredential<V0, M>>,
+    ) -> Self {
+        let credentials = credential_iter
+            .into_iter()
+            .map(|credential| {
+                (
+                    <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+                        V0,
+                    >>::precalculate::<P>(&credential.discovery_credential),
+                    credential.match_data,
+                )
+            })
+            .collect();
+        Self { credentials }
+    }
+}
+
+#[cfg(any(feature = "alloc", test))]
+/// A simple credentials source that holds owned, pre-calculated V1 crypto-materials.
+pub struct PrecalculatedOwnedV1CredentialSourceWithId<M: MatchedCredential> {
+    credentials:
+        Vec<
+            (
+                <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+                    V1,
+                >>::Output,
+                M,
+            ),
+        >,
+}
+
+#[cfg(any(feature = "alloc", test))]
+impl<M: MatchedCredential + Clone> PrecalculatedOwnedV1CredentialSourceWithId<M> {
+    /// Pre-calculates crypto material for the given credentials and constructs a
+    /// new source which owns this crypto-material.
+    pub fn new<P: CryptoProvider>(
+        credential_iter: impl IntoIterator<Item = MatchableCredential<V1, M>>,
+    ) -> Self {
+        let credentials = credential_iter
+            .into_iter()
+            .map(|credential| {
+                (
+                    <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+                        V1,
+                    >>::precalculate::<P>(&credential.discovery_credential),
+                    credential.match_data,
+                )
+            })
+            .collect();
+        Self { credentials }
+    }
+}
+
+#[cfg(any(feature = "alloc", test))]
+/// Mapping function for the iterator to return a reference to the crypto material
+/// and a clone of the match data.
+fn crypto_and_cloned_match_data<C, M: MatchedCredential + Clone>(pair_ref: &(C, M)) -> (&C, M) {
+    let (c, m) = pair_ref;
+    (c, m.clone())
+}
+
+#[cfg(any(feature = "alloc", test))]
+impl<'a, M: MatchedCredential + Clone> CredentialSource<'a, V0>
+    for PrecalculatedOwnedV0CredentialSourceWithId<M>
+where
+    Self: 'a,
+{
+    type Matched = M;
+    type Crypto = &'a <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+        V0,
+    >>::Output;
+    type Iterator = core::iter::Map<
+        core::slice::Iter<
+            'a,
+            (
+                <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+                    V0,
+                >>::Output,
+                M,
+            ),
+        >,
+        fn(
+            &'a (
+                <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+                    V0,
+                >>::Output,
+                M,
+            ),
+        ) -> (
+            &'a <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+                V0,
+            >>::Output,
+            M,
+        ),
+    >;
+
+    fn iter(&'a self) -> Self::Iterator {
+        self.credentials.iter().map(crypto_and_cloned_match_data)
+    }
+}
+
+#[cfg(any(feature = "alloc", test))]
+impl<'a, M: MatchedCredential + Clone> CredentialSource<'a, V1>
+    for PrecalculatedOwnedV1CredentialSourceWithId<M>
+where
+    Self: 'a,
+{
+    type Matched = M;
+    type Crypto = &'a <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+        V1,
+    >>::Output;
+    type Iterator = core::iter::Map<
+        core::slice::Iter<
+            'a,
+            (
+                <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+                    V1,
+                >>::Output,
+                M,
+            ),
+        >,
+        fn(
+            &'a (
+                <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+                    V1,
+                >>::Output,
+                M,
+            ),
+        ) -> (
+            &'a <precalculated_for_version::Marker as precalculated_for_version::MappingTrait<
+                V1,
+            >>::Output,
+            M,
+        ),
+    >;
+
+    fn iter(&'a self) -> Self::Iterator {
+        self.credentials.iter().map(crypto_and_cloned_match_data)
+    }
+}
diff --git a/nearby/presence/np_adv/src/credential/matched.rs b/nearby/presence/np_adv/src/credential/matched.rs
index e559976..51b2f1c 100644
--- a/nearby/presence/np_adv/src/credential/matched.rs
+++ b/nearby/presence/np_adv/src/credential/matched.rs
@@ -136,6 +136,37 @@
     }
 }
 
+/// A simple implementation of [`MatchedCredential`] where all match-data
+/// is contained in the encrypted metadata byte-field.
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct MatchedCredentialWithId<A: AsRef<[u8]> + Clone + Debug + PartialEq + Eq> {
+    encrypted_metadata: A,
+    id: i64,
+}
+
+impl<A: AsRef<[u8]> + Clone + Debug + PartialEq + Eq> MatchedCredentialWithId<A> {
+    /// Builds a new [`MatchedCredentialWithId`] with the given
+    /// encrypted metadata.
+    pub fn new(encrypted_metadata: A, id: i64) -> Self {
+        Self { encrypted_metadata, id }
+    }
+
+    /// Returns the unique ID of the matched credential.
+    pub fn id(&self) -> i64 {
+        self.id
+    }
+}
+
+impl<A: AsRef<[u8]> + Clone + Debug + PartialEq + Eq> MatchedCredential
+    for MatchedCredentialWithId<A>
+{
+    type EncryptedMetadata = A;
+    type EncryptedMetadataFetchError = Infallible;
+    fn fetch_encrypted_metadata(&self) -> Result<Self::EncryptedMetadata, Infallible> {
+        Ok(self.encrypted_metadata.clone())
+    }
+}
+
 /// Trivial implementation of [`MatchedCredential`] which consists of no match-data.
 /// Suitable for usage scenarios where the decoded advertisement contents matter,
 /// but not necessarily which devices generated the contents.
diff --git a/nearby/presence/np_adv/src/credential/tests.rs b/nearby/presence/np_adv/src/credential/tests.rs
index 00c99b9..2bb3286 100644
--- a/nearby/presence/np_adv/src/credential/tests.rs
+++ b/nearby/presence/np_adv/src/credential/tests.rs
@@ -16,19 +16,22 @@
 extern crate alloc;
 
 use crate::credential::matched::{
-    EmptyMatchedCredential, KeySeedMatchedCredential, ReferencedMatchedCredential,
+    EmptyMatchedCredential, KeySeedMatchedCredential, MatchedCredentialWithId,
+    ReferencedMatchedCredential,
 };
 use crate::credential::v1::MicSectionVerificationMaterial;
 use crate::credential::{
     book::{
-        init_cache_from_source, CachedCredentialSource, PossiblyCachedDiscoveryCryptoMaterialKind,
+        init_cache_from_source, CachedCredentialSource, CredentialBook, CredentialBookBuilder,
+        PossiblyCachedDiscoveryCryptoMaterialKind,
     },
     source::{CredentialSource, SliceCredentialSource},
     v0::{V0DiscoveryCredential, V0},
     v1::{V1BroadcastCredential, V1DiscoveryCredential, V1DiscoveryCryptoMaterial, V1},
-    MatchableCredential,
+    MatchableCredential, MatchedCredential,
 };
 use crate::extended::{V1IdentityToken, V1_IDENTITY_TOKEN_LEN};
+use alloc::vec;
 use alloc::vec::Vec;
 use crypto_provider_default::CryptoProviderImpl;
 
@@ -116,6 +119,50 @@
     }
 }
 
+#[test]
+fn precalculated_owned_book_with_id_iterates_correctly() {
+    let v0_creds: [MatchableCredential<V0, MatchedCredentialWithId<Vec<u8>>>; 2] =
+        [0, 1].map(|i| {
+            let match_data = MatchedCredentialWithId::new(vec![i], i as i64);
+            MatchableCredential {
+                discovery_credential: get_zeroed_v0_discovery_credential(),
+                match_data,
+            }
+        });
+    let v1_creds: [MatchableCredential<V1, MatchedCredentialWithId<Vec<u8>>>; 3] =
+        [2, 3, 4].map(|i| {
+            let match_data = MatchedCredentialWithId::new(vec![i], i as i64);
+            MatchableCredential {
+                discovery_credential: get_constant_packed_v1_discovery_credential(i),
+                match_data,
+            }
+        });
+
+    let v0_expected: Vec<_> = v0_creds
+        .iter()
+        .map(|c| (c.match_data.id(), c.match_data.fetch_encrypted_metadata().unwrap()))
+        .collect();
+
+    let v1_expected: Vec<_> = v1_creds
+        .iter()
+        .map(|c| (c.match_data.id(), c.match_data.fetch_encrypted_metadata().unwrap()))
+        .collect();
+
+    let book = CredentialBookBuilder::build_precalculated_owned_book_with_id::<CryptoProviderImpl>(
+        v0_creds, v1_creds,
+    );
+
+    let v0_from_book: Vec<_> =
+        book.v0_iter().map(|(_, m)| (m.id(), m.fetch_encrypted_metadata().unwrap())).collect();
+
+    assert_eq!(v0_from_book, v0_expected);
+
+    let v1_from_book: Vec<_> =
+        book.v1_iter().map(|(_, m)| (m.id(), m.fetch_encrypted_metadata().unwrap())).collect();
+
+    assert_eq!(v1_from_book, v1_expected);
+}
+
 mod coverage_gaming {
     use crate::credential::MetadataDecryptionError;
     use alloc::format;
@@ -123,6 +170,6 @@
     #[test]
     fn metadata_decryption_error_debug() {
         let err = MetadataDecryptionError;
-        let _ = format!("{:?}", err);
+        let _ = format!("{err:?}");
     }
 }
diff --git a/nearby/presence/np_adv/src/extended/data_elements/mod.rs b/nearby/presence/np_adv/src/extended/data_elements/mod.rs
index 98633a8..e22e8d1 100644
--- a/nearby/presence/np_adv/src/extended/data_elements/mod.rs
+++ b/nearby/presence/np_adv/src/extended/data_elements/mod.rs
@@ -57,6 +57,8 @@
         /// A media deduplication ID
         MediaDeduplicationId: MediaDeduplicationIdDataElement<'adv>,
     } owns {
+        /// Device Info
+        DeviceInfo: DeviceInfoDataElement,
         /// A transmission power
         TxPower: TxPowerDataElement,
         /// A context sync sequence number
@@ -467,6 +469,80 @@
     }
 }
 
+/// Device information of the broadcasting device.
+#[derive(Debug)]
+pub struct DeviceInfoDataElement {
+    /// Data element representing the device information of the broadcasting device,
+    /// including the [`DeviceType`], and device name, as well as a boolean flag
+    /// representing whether the device name is truncated.
+    pub device_info: DeviceInfo,
+}
+
+impl From<DeviceInfo> for DeviceInfoDataElement {
+    fn from(device_info: DeviceInfo) -> Self {
+        Self { device_info }
+    }
+}
+
+impl From<DeviceInfoDataElement> for DeviceInfo {
+    fn from(de: DeviceInfoDataElement) -> Self {
+        de.device_info
+    }
+}
+
+/// Errors raised when attempting to deserialize a device info DE.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum DeviceInfoDeserializationError {
+    /// The DE payload was of the wrong length.
+    WrongLength,
+    /// The device type is unknown
+    UnknownDeviceType,
+}
+
+impl HasDEType for DeviceInfoDataElement {
+    const DE_TYPE: DeType = DeType::const_from(0x03);
+}
+
+impl<'adv> DeserializedDataElement<'adv> for DeviceInfoDataElement {
+    type DeserializationError = DeviceInfoDeserializationError;
+    fn try_deserialize(
+        _maybe_salt: Option<&DeSalt>,
+        contents: &'adv [u8],
+    ) -> Result<Self, Self::DeserializationError> {
+        // Contents should be min 5 byte (device name) + 1 byte (device type | name truncated ).
+        if contents.len() < crate::shared_data::MIN_DEVICE_NAME_LEN + 1 {
+            return Err(DeviceInfoDeserializationError::WrongLength);
+        }
+
+        let type_and_trunc = contents[0];
+        let device_type = crate::shared_data::DeviceType::from_repr(type_and_trunc & 0b01111111)
+            .ok_or(DeviceInfoDeserializationError::UnknownDeviceType)?;
+        let name_truncated = (type_and_trunc & 0b10000000) != 0;
+        let device_name = &contents[1..];
+
+        let device_info = DeviceInfo::try_from((device_type, name_truncated, device_name))
+            .map_err(|_| {
+                // This is not ideal, but we don't have a specific error for this.
+                // The length is already checked, so this should not happen.
+                DeviceInfoDeserializationError::WrongLength
+            })?;
+
+        Ok(device_info.into())
+    }
+}
+
+impl WriteDataElement for DeviceInfoDataElement {
+    type Salt = Unsalted;
+    fn write_de_contents<S: Sink<u8>>(&self, _salt: Self::Salt, sink: &mut S) -> Option<()> {
+        let mut type_and_trunc = self.device_info.device_type() as u8;
+        if self.device_info.name_truncated() {
+            type_and_trunc |= 0b10000000;
+        }
+        sink.try_push(type_and_trunc)?;
+        sink.try_extend_from_slice(self.device_info.device_name())
+    }
+}
+
 /// Combined connectivity information about a device.
 #[derive(Clone, Debug, Default, PartialEq, Eq)]
 pub struct ConnectivityInfoDataElement {
diff --git a/nearby/presence/np_adv/src/extended/data_elements/tests.rs b/nearby/presence/np_adv/src/extended/data_elements/tests.rs
index d17e92b..f0882bf 100644
--- a/nearby/presence/np_adv/src/extended/data_elements/tests.rs
+++ b/nearby/presence/np_adv/src/extended/data_elements/tests.rs
@@ -197,6 +197,64 @@
 }
 
 #[test]
+fn deserialize_device_info() {
+    // Success case, with a recognized device type.
+    let device_type = crate::shared_data::DeviceType::TV;
+    let device_name = b"my-tv";
+    let mut device_info_bytes = Vec::new();
+    device_info_bytes.push(device_type as u8);
+    device_info_bytes.extend_from_slice(device_name);
+
+    let de = DataElement::new(DeviceInfoDataElement::DE_TYPE, &device_info_bytes, None);
+    let de = DeserializedGoogleDE::try_deserialize(de).expect("Should recognize a device info DE.");
+    let DeserializedGoogleDE::DeviceInfo(de) = de else {
+        panic!("Device info data elements don't deserialize to device info?");
+    };
+    let de = de.expect("Well formed device info DEs should always deserialize.");
+    assert_eq!(de.device_info.device_type(), device_type);
+    assert!(!de.device_info.name_truncated());
+    assert_eq!(de.device_info.device_name(), device_name);
+
+    // Failure case: wrong length
+    let de = DataElement::new(DeviceInfoDataElement::DE_TYPE, &device_info_bytes[..5], None);
+    let de = DeserializedGoogleDE::try_deserialize(de)
+        .expect("Should recognize even a malformatted device info DE.");
+    let DeserializedGoogleDE::DeviceInfo(de) = de else {
+        panic!("Device info data elements don't deserialize to device info?");
+    };
+    let err = de.expect_err("Device infos that are too short should not deserialize.");
+    assert_eq!(DeviceInfoDeserializationError::WrongLength, err);
+}
+
+#[test]
+fn serialize_device_info() {
+    let device_info = DeviceInfo::try_from((
+        crate::shared_data::DeviceType::Laptop,
+        true,
+        "my-laptop".as_bytes(),
+    ))
+    .unwrap();
+    let de = DeviceInfoDataElement::from(device_info);
+    let mut sink: ArrayVec<[u8; 10]> = ArrayVec::new();
+    de.write_de_contents(Unsalted, &mut sink).expect("Should be able to write a device info.");
+    assert_eq!(
+        sink.as_slice(),
+        &[
+            crate::shared_data::DeviceType::Laptop as u8 | 0b10000000,
+            b'm',
+            b'y',
+            b'-',
+            b'l',
+            b'a',
+            b'p',
+            b't',
+            b'o',
+            b'p'
+        ]
+    );
+}
+
+#[test]
 fn serialize_tx_power_de() {
     let mut adv_builder = AdvBuilder::new();
     let mut section_builder = adv_builder.section_builder(UnencryptedSectionEncoder).unwrap();
@@ -470,7 +528,7 @@
     // four bits are explicitly disallowed.
     for disallowed_bitmask in 1u8..=15u8 {
         let _ = BleConnectivityInfo::parse_from_payload(&[disallowed_bitmask])
-            .expect_err(&format!("Bitmask {} should be disallowed", disallowed_bitmask));
+            .expect_err(&format!("Bitmask {disallowed_bitmask} should be disallowed"));
     }
 }
 
@@ -480,7 +538,7 @@
     // four bits are explicitly disallowed.
     for disallowed_bitmask in 1u8..=15u8 {
         let _ = WifiLanConnectivityInfo::parse_from_payload(&[disallowed_bitmask])
-            .expect_err(&format!("Bitmask {} should be disallowed", disallowed_bitmask));
+            .expect_err(&format!("Bitmask {disallowed_bitmask} should be disallowed"));
     }
 }
 
@@ -614,7 +672,7 @@
     #[test]
     fn generic_de_error_derives() {
         let err = GenericDataElementError::DataTooLong;
-        let _ = format!("{:?}", err);
+        let _ = format!("{err:?}");
         assert_eq!(err, err);
     }
 
@@ -622,6 +680,6 @@
     fn generic_data_element_debug() {
         let generic =
             GenericDataElement::try_from(DeType::from(1000_u16), &[10, 11, 12, 13]).unwrap();
-        let _ = format!("{:?}", generic);
+        let _ = format!("{generic:?}");
     }
 }
diff --git a/nearby/presence/np_adv/src/extended/deserialize/data_element/tests.rs b/nearby/presence/np_adv/src/extended/deserialize/data_element/tests.rs
index 6187199..73f6307 100644
--- a/nearby/presence/np_adv/src/extended/deserialize/data_element/tests.rs
+++ b/nearby/presence/np_adv/src/extended/deserialize/data_element/tests.rs
@@ -294,12 +294,12 @@
     #[test]
     fn data_element_debug_and_clone() {
         let de = DataElement::new(0_u8.into(), &[], None);
-        let _ = format!("{:?}", de);
+        let _ = format!("{de:?}");
         let _ = de.clone();
         let (_, header) = DeHeader::parse(&[0x11, 0xFF]).unwrap();
-        let _ = format!("{:?}", header);
+        let _ = format!("{header:?}");
         let _ = header.clone();
         let (_, pde) = ProtoDataElement::parse(&[0x11, 0xFF]).unwrap();
-        let _ = format!("{:?}", pde);
+        let _ = format!("{pde:?}");
     }
 }
diff --git a/nearby/presence/np_adv/src/extended/deserialize/encrypted_section/tests/coverage_gaming.rs b/nearby/presence/np_adv/src/extended/deserialize/encrypted_section/tests/coverage_gaming.rs
index 4d3c79f..1d3f427 100644
--- a/nearby/presence/np_adv/src/extended/deserialize/encrypted_section/tests/coverage_gaming.rs
+++ b/nearby/presence/np_adv/src/extended/deserialize/encrypted_section/tests/coverage_gaming.rs
@@ -26,18 +26,18 @@
     let token = CiphertextExtendedIdentityToken([0; 16]);
     let section = SectionIdentityResolutionContents { identity_token: token, nonce };
     assert_eq!(section, section);
-    let _ = format!("{:?}", section);
+    let _ = format!("{section:?}");
 }
 
 #[test]
 fn error_enum_debug_derives() {
     let mic_err = MicVerificationError::MicMismatch;
-    let _ = format!("{:?}", mic_err);
+    let _ = format!("{mic_err:?}");
 
     let deser_err = DeserializationError::ArenaOutOfSpace::<MicVerificationError>;
-    let _ = format!("{:?}", deser_err);
+    let _ = format!("{deser_err:?}");
 
     let err =
         IdentityResolutionOrDeserializationError::IdentityMatchingError::<MicVerificationError>;
-    let _ = format!("{:?}", err);
+    let _ = format!("{err:?}");
 }
diff --git a/nearby/presence/np_adv/src/extended/deserialize/section/header/tests.rs b/nearby/presence/np_adv/src/extended/deserialize/section/header/tests.rs
index 0db8a4f..71dd603 100644
--- a/nearby/presence/np_adv/src/extended/deserialize/section/header/tests.rs
+++ b/nearby/presence/np_adv/src/extended/deserialize/section/header/tests.rs
@@ -104,6 +104,6 @@
             salt: [0; 2].into(),
             token: CiphertextExtendedIdentityToken([0; 16]),
         };
-        let _ = format!("{:?}", header);
+        let _ = format!("{header:?}");
     }
 }
diff --git a/nearby/presence/np_adv/src/extended/deserialize/section/intermediate/tests.rs b/nearby/presence/np_adv/src/extended/deserialize/section/intermediate/tests.rs
index 276536f..65ee407 100644
--- a/nearby/presence/np_adv/src/extended/deserialize/section/intermediate/tests.rs
+++ b/nearby/presence/np_adv/src/extended/deserialize/section/intermediate/tests.rs
@@ -501,14 +501,14 @@
             contents: &[],
             contents_len: 0,
         };
-        let _ = format!("{:?}", sc);
+        let _ = format!("{sc:?}");
         assert_eq!(sc, sc);
     }
 
     #[test]
     fn intermediate_section() {
         let is = IntermediateSection::Plaintext(PlaintextSection::new(&[]));
-        let _ = format!("{:?}", is);
+        let _ = format!("{is:?}");
     }
     #[test]
     fn ciphertext_section() {
@@ -524,7 +524,7 @@
             mic: [0x00; 16].into(),
         };
         let cs = CiphertextSection::MicEncrypted(ms);
-        let _ = format!("{:?}", cs);
+        let _ = format!("{cs:?}");
     }
 }
 
diff --git a/nearby/presence/np_adv/src/extended/deserialize/tests.rs b/nearby/presence/np_adv/src/extended/deserialize/tests.rs
index 478fd78..3dfaebf 100644
--- a/nearby/presence/np_adv/src/extended/deserialize/tests.rs
+++ b/nearby/presence/np_adv/src/extended/deserialize/tests.rs
@@ -96,21 +96,21 @@
             [0u8; 16].into(),
             &[],
         );
-        let _ = format!("{:?}", d);
+        let _ = format!("{d:?}");
     }
 
     #[test]
     fn section_deserialize_error_derives() {
         let e = SectionDeserializeError::IncorrectCredential;
         assert_eq!(e, e);
-        let _ = format!("{:?}", e);
+        let _ = format!("{e:?}");
     }
 
     #[test]
     fn adv_contents_derives() {
         let c: V1AdvertisementContents<'_, EmptyMatchedCredential> =
             V1AdvertisementContents::new(ArrayVecOption::default(), 0);
-        let _ = format!("{:?}", c);
+        let _ = format!("{c:?}");
         assert_eq!(c, c);
     }
 
@@ -119,7 +119,7 @@
         let d: V1DeserializedSection<'_, EmptyMatchedCredential> =
             V1DeserializedSection::Plaintext(PlaintextSection::new(&[]));
         assert_eq!(d, d);
-        let _ = format!("{:?}", d);
+        let _ = format!("{d:?}");
     }
 
     #[test]
diff --git a/nearby/presence/np_adv/src/extended/serialize/de_header_tests.rs b/nearby/presence/np_adv/src/extended/serialize/de_header_tests.rs
index ef8f5dc..92e5e45 100644
--- a/nearby/presence/np_adv/src/extended/serialize/de_header_tests.rs
+++ b/nearby/presence/np_adv/src/extended/serialize/de_header_tests.rs
@@ -103,7 +103,7 @@
         1
     } else {
         // at least one type byte to handle the type = 0 case
-        1_u8 + cmp::max(1, (32 - hdr.de_type.as_u32().leading_zeros() as u8 + 6) / 7)
+        1_u8 + cmp::max(1, (32 - hdr.de_type.as_u32().leading_zeros() as u8).div_ceil(7))
     }
 }
 
diff --git a/nearby/presence/np_adv/src/extended/serialize/test_vectors.rs b/nearby/presence/np_adv/src/extended/serialize/test_vectors.rs
index 7538d47..93b602c 100644
--- a/nearby/presence/np_adv/src/extended/serialize/test_vectors.rs
+++ b/nearby/presence/np_adv/src/extended/serialize/test_vectors.rs
@@ -127,13 +127,13 @@
             (0..num_des)
                 .map(|de_index| {
                     let de_len = test_vector_seed_hkdf
-                        .derive_range_element(&format!("de len {}", de_index), 0..=30);
+                        .derive_range_element(&format!("de len {de_index}"), 0..=30);
                     let data = test_vector_seed_hkdf
-                        .derive_vec(&format!("de data {}", de_index), de_len.try_into().unwrap());
+                        .derive_vec(&format!("de data {de_index}"), de_len.try_into().unwrap());
                     DummyDataElement {
                         de_type: DeType::try_from(
                             u32::try_from(test_vector_seed_hkdf.derive_range_element(
-                                &format!("de type {}", de_index),
+                                &format!("de type {de_index}"),
                                 0_u64..=1_000,
                             ))
                             .unwrap(),
diff --git a/nearby/presence/np_adv/src/filter/tests/actions_filter_tests.rs b/nearby/presence/np_adv/src/filter/tests/actions_filter_tests.rs
index c395124..c037031 100644
--- a/nearby/presence/np_adv/src/filter/tests/actions_filter_tests.rs
+++ b/nearby/presence/np_adv/src/filter/tests/actions_filter_tests.rs
@@ -15,14 +15,15 @@
 #![allow(clippy::unwrap_used)]
 
 use super::super::*;
-use crate::legacy::data_elements::actions::{ActionBits, InstantTethering, NearbyShare};
+use crate::legacy::data_elements::actions::tests::{
+    ACTIVE_UNLOCK, CALL_TRANSFER, INSTANT_TETHERING, NEARBY_SHARE, PHONE_HUB,
+};
+use crate::legacy::data_elements::actions::ActionBits;
 use crate::legacy::{Ciphertext, Plaintext};
-use alloc::vec::Vec;
-use strum::IntoEnumIterator;
 
 #[test]
 fn new_v0_actions_invalid_length() {
-    let actions = [actions::ActionType::ActiveUnlock; 8];
+    let actions = [ACTIVE_UNLOCK; 8];
     let result = V0ActionsFilter::new_from_slice(&actions);
     assert!(result.is_err());
     assert_eq!(result.err().unwrap(), InvalidLength)
@@ -30,7 +31,7 @@
 
 #[test]
 fn new_v0_actions() {
-    let actions = [actions::ActionType::ActiveUnlock; 5];
+    let actions = [ACTIVE_UNLOCK; 5];
     let result = V0ActionsFilter::new_from_slice(&actions);
     assert!(result.is_ok());
 }
@@ -46,8 +47,7 @@
     // default is all 0 bits
     let action_bits = ActionBits::<Plaintext>::default();
 
-    let filter = V0ActionsFilter::new_from_slice(&[actions::ActionType::ActiveUnlock; 1])
-        .expect("1 is a valid length");
+    let filter = V0ActionsFilter::new_from_slice(&[ACTIVE_UNLOCK; 1]).expect("1 is a valid length");
 
     assert_eq!(filter.match_v0_actions(&action_bits.into()), Err(NoMatch))
 }
@@ -57,8 +57,7 @@
     // default is all 0 bits
     let action_bits = ActionBits::<Plaintext>::default();
 
-    let filter = V0ActionsFilter::new_from_slice(&actions::ActionType::iter().collect::<Vec<_>>())
-        .expect("5 is a valid length");
+    let filter = V0ActionsFilter::new_from_slice(&[ACTIVE_UNLOCK; 1]).expect("5 is a valid length");
 
     assert_eq!(filter.match_v0_actions(&action_bits.into()), Err(NoMatch))
 }
@@ -67,9 +66,9 @@
 fn test_actions_filter_single_action_present() {
     // default is all 0 bits
     let mut action_bits = ActionBits::<Plaintext>::default();
-    action_bits.set_action(NearbyShare::from(true));
+    action_bits.set_action(NEARBY_SHARE);
 
-    let filter = V0ActionsFilter::new_from_slice(&actions::ActionType::iter().collect::<Vec<_>>())
+    let filter = V0ActionsFilter::new_from_slice(&[ACTIVE_UNLOCK, NEARBY_SHARE])
         .expect("5 is a valid length");
 
     assert_eq!(filter.match_v0_actions(&action_bits.into()), Ok(()))
@@ -79,13 +78,13 @@
 fn test_actions_filter_desired_action_not_present() {
     // default is all 0 bits
     let mut action_bits = ActionBits::<Plaintext>::default();
-    action_bits.set_action(NearbyShare::from(true));
+    action_bits.set_action(NEARBY_SHARE);
 
     let filter = V0ActionsFilter::new_from_slice(&[
-        actions::ActionType::CallTransfer,
-        actions::ActionType::ActiveUnlock,
-        actions::ActionType::InstantTethering,
-        actions::ActionType::PhoneHub,
+        CALL_TRANSFER,
+        ACTIVE_UNLOCK,
+        INSTANT_TETHERING,
+        PHONE_HUB,
     ])
     .expect("4 is a valid length");
 
@@ -96,11 +95,11 @@
 fn test_multiple_actions_set() {
     // default is all 0 bits
     let mut action_bits = ActionBits::<Ciphertext>::default();
-    action_bits.set_action(NearbyShare::from(true));
-    action_bits.set_action(InstantTethering::from(true));
+    action_bits.set_action(NEARBY_SHARE);
+    action_bits.set_action(INSTANT_TETHERING);
 
-    let filter = V0ActionsFilter::new_from_slice(&[actions::ActionType::InstantTethering])
-        .expect("1 is a valid length");
+    let filter =
+        V0ActionsFilter::new_from_slice(&[INSTANT_TETHERING]).expect("1 is a valid length");
 
     assert_eq!(filter.match_v0_actions(&action_bits.into()), Ok(()))
 }
@@ -109,19 +108,11 @@
 fn test_multiple_actions_set_both_present() {
     // default is all 0 bits
     let mut action_bits = ActionBits::<Ciphertext>::default();
-    action_bits.set_action(NearbyShare::from(true));
-    action_bits.set_action(InstantTethering::from(true));
+    action_bits.set_action(NEARBY_SHARE);
+    action_bits.set_action(INSTANT_TETHERING);
 
-    let filter = V0ActionsFilter::new_from_slice(&[
-        actions::ActionType::InstantTethering,
-        actions::ActionType::NearbyShare,
-    ])
-    .expect("7 is a valid length");
+    let filter = V0ActionsFilter::new_from_slice(&[INSTANT_TETHERING, NEARBY_SHARE])
+        .expect("7 is a valid length");
 
     assert_eq!(filter.match_v0_actions(&action_bits.into()), Ok(()))
 }
-
-#[test]
-fn num_actions_is_correct() {
-    assert_eq!(actions::ActionType::iter().count(), NUM_ACTIONS);
-}
diff --git a/nearby/presence/np_adv/src/filter/tests/data_elements_filter_tests.rs b/nearby/presence/np_adv/src/filter/tests/data_elements_filter_tests.rs
index 0b83831..e19d41e 100644
--- a/nearby/presence/np_adv/src/filter/tests/data_elements_filter_tests.rs
+++ b/nearby/presence/np_adv/src/filter/tests/data_elements_filter_tests.rs
@@ -16,8 +16,7 @@
 use crate::{
     legacy::{
         data_elements::{
-            actions::{ActionBits, ActiveUnlock},
-            tx_power::TxPowerDataElement,
+            actions::tests::ACTIVE_UNLOCK, actions::ActionBits, tx_power::TxPowerDataElement,
         },
         Ciphertext, Plaintext,
     },
@@ -45,8 +44,7 @@
 
 #[test]
 fn match_not_contains_actions() {
-    let filter = V0ActionsFilter::new_from_slice(&[actions::ActionType::ActiveUnlock; 1])
-        .expect("1 is a valid length");
+    let filter = V0ActionsFilter::new_from_slice(&[ACTIVE_UNLOCK; 1]).expect("1 is a valid length");
     let filter = V0DataElementsFilter { contains_tx_power: None, actions_filter: Some(filter) };
     let tx_power = TxPower::try_from(5).expect("within range");
     let result = filter.match_v0_legible_adv(|| {
@@ -58,13 +56,12 @@
 
 #[test]
 fn match_contains_actions() {
-    let filter = V0ActionsFilter::new_from_slice(&[actions::ActionType::ActiveUnlock; 1])
-        .expect("1 is a valid length");
+    let filter = V0ActionsFilter::new_from_slice(&[ACTIVE_UNLOCK; 1]).expect("1 is a valid length");
     let filter = V0DataElementsFilter { contains_tx_power: None, actions_filter: Some(filter) };
     let tx_power = TxPower::try_from(5).expect("within range");
 
     let mut action_bits = ActionBits::<Ciphertext>::default();
-    action_bits.set_action(ActiveUnlock::from(true));
+    action_bits.set_action(ACTIVE_UNLOCK);
 
     let result = filter.match_v0_legible_adv(|| {
         [
@@ -78,14 +75,13 @@
 
 #[test]
 fn match_contains_both() {
-    let filter = V0ActionsFilter::new_from_slice(&[actions::ActionType::ActiveUnlock; 1])
-        .expect("1 is a valid length");
+    let filter = V0ActionsFilter::new_from_slice(&[ACTIVE_UNLOCK; 1]).expect("1 is a valid length");
     let filter =
         V0DataElementsFilter { contains_tx_power: Some(true), actions_filter: Some(filter) };
     let tx_power = TxPower::try_from(5).expect("within range");
 
     let mut action_bits = ActionBits::<Ciphertext>::default();
-    action_bits.set_action(ActiveUnlock::from(true));
+    action_bits.set_action(ACTIVE_UNLOCK);
 
     let result = filter.match_v0_legible_adv(|| {
         [
@@ -99,8 +95,7 @@
 
 #[test]
 fn match_contains_either() {
-    let filter = V0ActionsFilter::new_from_slice(&[actions::ActionType::ActiveUnlock; 1])
-        .expect("1 is a valid length");
+    let filter = V0ActionsFilter::new_from_slice(&[ACTIVE_UNLOCK; 1]).expect("1 is a valid length");
     let filter =
         V0DataElementsFilter { contains_tx_power: Some(true), actions_filter: Some(filter) };
     let tx_power = TxPower::try_from(5).expect("within range");
diff --git a/nearby/presence/np_adv/src/legacy/data_elements/actions/macros.rs b/nearby/presence/np_adv/src/legacy/data_elements/actions/macros.rs
deleted file mode 100644
index 3d1ab48..0000000
--- a/nearby/presence/np_adv/src/legacy/data_elements/actions/macros.rs
+++ /dev/null
@@ -1,124 +0,0 @@
-// Copyright 2022 Google LLC
-//
-// 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
-//
-//      http://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.
-
-/// Create a struct holding a `bool`.
-macro_rules! boolean_element_struct {
-    ($type_name:ident) => {
-        #[derive(Debug)]
-        #[allow(missing_docs)]
-        pub struct $type_name {
-            enabled: bool,
-        }
-    };
-}
-
-/// Create a `From<bool>` impl for a struct made with [boolean_element_struct].
-macro_rules! boolean_element_struct_from_bool {
-    ($type_name:ident) => {
-        impl From<bool> for $type_name {
-            fn from(value: bool) -> Self {
-                $type_name { enabled: value }
-            }
-        }
-    };
-}
-
-/// The guts of an ActionElement impl
-macro_rules! boolean_element_action_element_impl_shared {
-    ($type_name:ident, $index:expr) => {
-        const HIGH_BIT_INDEX: u32 = $index;
-        const ACTION_TYPE: $crate::legacy::data_elements::actions::ActionType =
-            $crate::legacy::data_elements::actions::ActionType::$type_name;
-    };
-}
-
-/// Create a struct w/ `From<bool>` and [`ActionElement`](super::ActionElement) impls.
-/// Use `plaintext_only`, `ciphertext_only`, or `plaintext_and_ciphertext` to create appropriate
-/// impls.
-macro_rules! boolean_element {
-    ($type_name:ident, $index:expr, ciphertext_only) => {
-        $crate::legacy::data_elements::actions::macros::boolean_element_struct!($type_name);
-        $crate::legacy::data_elements::actions::macros::boolean_element_struct_from_bool!($type_name);
-
-        impl $crate::legacy::data_elements::actions::ActionElement for $type_name {
-            $crate::legacy::data_elements::actions::macros::boolean_element_action_element_impl_shared!(
-                $type_name, $index
-            );
-
-            fn supports_flavor(flavor: $crate::legacy::PacketFlavorEnum) -> bool {
-                match flavor {
-                    $crate::legacy::PacketFlavorEnum::Plaintext => false,
-                    $crate::legacy::PacketFlavorEnum::Ciphertext => true,
-                }
-            }
-
-            fn bits(&self) -> u8 {
-                self.enabled as u8
-            }
-        }
-
-        $crate::legacy::data_elements::actions::macros::boolean_element_to_encrypted_element!($type_name);
-    };
-    ($type_name:ident, $index:expr, plaintext_and_ciphertext) => {
-        $crate::legacy::data_elements::actions::macros::boolean_element_struct!($type_name);
-        $crate::legacy::data_elements::actions::macros::boolean_element_struct_from_bool!($type_name);
-
-        impl $crate::legacy::data_elements::actions::ActionElement for $type_name {
-            $crate::legacy::data_elements::actions::macros::boolean_element_action_element_impl_shared!(
-                $type_name, $index
-            );
-
-            fn supports_flavor(flavor: $crate::legacy::PacketFlavorEnum) -> bool {
-                match flavor {
-                    $crate::legacy::PacketFlavorEnum::Plaintext => true,
-                    $crate::legacy::PacketFlavorEnum::Ciphertext => true,
-                }
-            }
-
-            fn bits(&self) -> u8 {
-                self.enabled as u8
-            }
-        }
-
-        $crate::legacy::data_elements::actions::macros::boolean_element_to_plaintext_element!($type_name);
-        $crate::legacy::data_elements::actions::macros::boolean_element_to_encrypted_element!($type_name);
-    };
-}
-
-/// Create a [`ToActionElement<Encrypted>`](super::ActionElementFlavor) impl with the given index and length 1.
-macro_rules! boolean_element_to_encrypted_element {
-    ( $type_name:ident) => {
-        impl $crate::legacy::data_elements::actions::ActionElementFlavor<$crate::legacy::Ciphertext>
-            for $type_name
-        {
-        }
-    };
-}
-
-/// Create a [`ToActionElement<Plaintext>`](super::ActionElementFlavor) impl with the given index and length 1.
-macro_rules! boolean_element_to_plaintext_element {
-    ( $type_name:ident) => {
-        impl $crate::legacy::data_elements::actions::ActionElementFlavor<$crate::legacy::Plaintext>
-            for $type_name
-        {
-        }
-    };
-}
-
-// expose macros to the rest of the crate without macro_export's behavior of exporting at crate root
-pub(crate) use {
-    boolean_element, boolean_element_action_element_impl_shared, boolean_element_struct,
-    boolean_element_struct_from_bool, boolean_element_to_encrypted_element,
-    boolean_element_to_plaintext_element,
-};
diff --git a/nearby/presence/np_adv/src/legacy/data_elements/actions/mod.rs b/nearby/presence/np_adv/src/legacy/data_elements/actions/mod.rs
index 36ac35d..5010936 100644
--- a/nearby/presence/np_adv/src/legacy/data_elements/actions/mod.rs
+++ b/nearby/presence/np_adv/src/legacy/data_elements/actions/mod.rs
@@ -35,9 +35,7 @@
 use core::{marker, ops};
 use nom::{bytes, combinator, error};
 use sink::Sink;
-use strum::IntoEnumIterator as _;
 
-mod macros;
 #[cfg(test)]
 pub(crate) mod tests;
 
@@ -52,7 +50,7 @@
 }
 
 /// Max length of an actions DE contents
-pub(crate) const ACTIONS_MAX_LEN: usize = 3;
+pub(crate) const ACTIONS_MAX_LEN: usize = 4;
 /// Range of valid actual lengths
 pub(crate) const ACTIONS_VALID_ACTUAL_LEN: ops::RangeInclusive<usize> = 1..=ACTIONS_MAX_LEN;
 
@@ -75,14 +73,9 @@
         ))(de_contents)
         .map_err(|_| DataElementDeserializeError::DeserializeError { de_type: Self::DE_TYPE_CODE })
         .map(|(_remaining, actions)| actions)
-        .and_then(|action_bits_num| {
-            let action = ActionBits::try_from(action_bits_num).map_err(|e| {
-                DataElementDeserializeError::FlavorNotSupported {
-                    de_type: Self::DE_TYPE_CODE,
-                    flavor: e.flavor,
-                }
-            })?;
-            Ok(Self { action })
+        .map(|action_bits_num| {
+            let action = ActionBits::from(action_bits_num);
+            Self { action }
         })
     }
 }
@@ -166,6 +159,11 @@
     pub fn has_action(&self, action_type: ActionType) -> bool {
         self.bits_for_type(action_type) != 0
     }
+
+    /// Return a list of all ActionType contained within the ActionBits.
+    pub fn actions(&self) -> impl Iterator<Item = ActionType> + use<'_, F> {
+        (0..ActionType::MAX_ACTION_ID).map(ActionType).filter(|action| self.has_action(*action))
+    }
 }
 
 impl<F: PacketFlavor> Default for ActionBits<F> {
@@ -183,46 +181,20 @@
     flavor: PacketFlavorEnum,
 }
 
-lazy_static::lazy_static! {
-    /// All bits for plaintext action types: 1 where a plaintext action could have a bit, 0 elsewhere.
-    static ref ALL_PLAINTEXT_ELEMENT_BITS: u32 = ActionType::iter()
-        .filter(|t| t.supports_flavor(PacketFlavorEnum::Plaintext))
-        .map(|t| t.all_bits())
-        .fold(0_u32, |accum, bits| accum | bits);
-}
-
-lazy_static::lazy_static! {
-    /// All bits for ciphertext action types: 1 where a ciphertext action could have a bit, 0 elsewhere.
-    static ref ALL_CIPHERTEXT_ELEMENT_BITS: u32 = ActionType::iter()
-        .filter(|t| t.supports_flavor(PacketFlavorEnum::Ciphertext))
-        .map(|t| t.all_bits())
-        .fold(0_u32, |accum, bits| accum | bits);
-}
-
 impl<F: PacketFlavor> ActionBits<F> {
     /// Tries to create ActionBits from a u32, returning error in the event a specific bit is set for
     /// an unsupported flavor
-    pub fn try_from(value: u32) -> Result<Self, FlavorNotSupported> {
-        let ok_bits: u32 = match F::ENUM_VARIANT {
-            PacketFlavorEnum::Plaintext => *ALL_PLAINTEXT_ELEMENT_BITS,
-            PacketFlavorEnum::Ciphertext => *ALL_CIPHERTEXT_ELEMENT_BITS,
-        };
-
-        // no bits set beyond what's allowed for this flavor
-        if value | ok_bits == ok_bits {
-            Ok(Self { bits: value, flavor: marker::PhantomData })
-        } else {
-            Err(FlavorNotSupported { flavor: F::ENUM_VARIANT })
-        }
+    pub fn from(value: u32) -> Self {
+        Self { bits: value, flavor: marker::PhantomData }
     }
 
     /// Set the bits for the provided element.
     /// Bits outside the range set by the action will be unaffected.
-    pub fn set_action<E: ActionElementFlavor<F>>(&mut self, action_element: E) {
+    pub fn set_action<E: ActionElement>(&mut self, action_element: E) {
         let bits = action_element.bits();
 
         // validate that the element is not horribly broken
-        debug_assert!(E::HIGH_BIT_INDEX < 32);
+        debug_assert!(action_element.high_bit_index() < 32);
         // must not have bits set past the low `len` bits
         debug_assert_eq!(0, bits >> 1);
 
@@ -230,16 +202,16 @@
         let byte_extended = bits as u32;
         // Shift so that the high bit is at the desired index.
         // Won't overflow since length > 0.
-        let bits_in_position = byte_extended << (31 - E::HIGH_BIT_INDEX);
+        let bits_in_position = byte_extended << (31 - action_element.high_bit_index());
 
         // We want to effectively clear out the bits already in place, so we don't want to just |=.
         // Instead, we construct a u32 with all 1s above and below the relevant bits and &=, so that
         // if the new bits are 0, the stored bits will be cleared.
 
         // avoid overflow when index = 0 -- need zero 1 bits to the left in that case
-        let left_1s = u32::MAX.checked_shl(32 - E::HIGH_BIT_INDEX).unwrap_or(0);
+        let left_1s = u32::MAX.checked_shl(32 - action_element.high_bit_index()).unwrap_or(0);
         // avoid underflow when index + len = 32 -- zero 1 bits to the right
-        let right_1s = u32::MAX.checked_shr(E::HIGH_BIT_INDEX + 1).unwrap_or(0);
+        let right_1s = u32::MAX.checked_shr(action_element.high_bit_index() + 1).unwrap_or(0);
         let mask = left_1s | right_1s;
         let bits_for_other_actions = self.bits & mask;
         self.bits = bits_for_other_actions | bits_in_position;
@@ -250,7 +222,7 @@
     /// an actions field of all zeroes is required by the spec to occupy exactly one byte.
     fn bytes_used(&self) -> usize {
         let bits_used = 32 - self.bits.trailing_zeros();
-        let raw_count = (bits_used as usize + 7) / 8;
+        let raw_count = (bits_used as usize).div_ceil(8);
         if raw_count == 0 {
             1 // Uncommon case - should only be hit for all-zero action bits
         } else {
@@ -271,21 +243,10 @@
 pub trait ActionElement {
     /// The assigned offset for this type from the high bit in the eventual bit sequence of all
     /// actions.
-    ///
-    /// Each implementation must have a non-conflicting index defined by
-    /// [Self::HIGH_BIT_INDEX]
-    const HIGH_BIT_INDEX: u32;
-
-    /// Forces implementations to have a matching enum variant so the enum can be kept up to date.
-    const ACTION_TYPE: ActionType;
-
-    /// Returns whether this action supports the provided `flavor`.
-    ///
-    /// Must match the implementations of [ActionElementFlavor].
-    fn supports_flavor(flavor: PacketFlavorEnum) -> bool;
+    fn high_bit_index(&self) -> u32;
 
     /// Returns the low bit that should be included in the final bit vector
-    /// starting at [Self::HIGH_BIT_INDEX].
+    /// starting at [Self::high_bit_index()].
     fn bits(&self) -> u8;
 }
 
@@ -293,23 +254,18 @@
 pub trait ActionElementFlavor<F: PacketFlavor>: ActionElement {}
 
 /// Provides a way to iterate over all action types.
-#[derive(Clone, Copy, strum_macros::EnumIter, PartialEq, Eq, Hash, Debug)]
+#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
 #[allow(missing_docs)]
-pub enum ActionType {
-    CrossDevSdk,
-    CallTransfer,
-    ActiveUnlock,
-    NearbyShare,
-    InstantTethering,
-    PhoneHub,
-}
+pub struct ActionType(pub u8);
 
 impl ActionType {
+    const MAX_ACTION_ID: u8 = 31;
+
+    #[cfg(test)]
     /// A u32 with all possible bits for this action type set
     const fn all_bits(&self) -> u32 {
         (u32::MAX << (31_u32)) >> self.high_bit_index()
     }
-
     /// Get the range of the bits occupied used by this bit index. For example, if the action type
     /// uses the 5th and 6th bits, the returned range will be (5..7).
     /// (0 is the index of the most significant bit).
@@ -320,32 +276,16 @@
     }
 
     const fn high_bit_index(&self) -> u32 {
-        match self {
-            ActionType::CrossDevSdk => CrossDevSdk::HIGH_BIT_INDEX,
-            ActionType::CallTransfer => CallTransfer::HIGH_BIT_INDEX,
-            ActionType::ActiveUnlock => ActiveUnlock::HIGH_BIT_INDEX,
-            ActionType::NearbyShare => NearbyShare::HIGH_BIT_INDEX,
-            ActionType::InstantTethering => InstantTethering::HIGH_BIT_INDEX,
-            ActionType::PhoneHub => PhoneHub::HIGH_BIT_INDEX,
-        }
-    }
-
-    pub(crate) fn supports_flavor(&self, flavor: PacketFlavorEnum) -> bool {
-        match self {
-            ActionType::CrossDevSdk => CrossDevSdk::supports_flavor(flavor),
-            ActionType::CallTransfer => CallTransfer::supports_flavor(flavor),
-            ActionType::ActiveUnlock => ActiveUnlock::supports_flavor(flavor),
-            ActionType::NearbyShare => NearbyShare::supports_flavor(flavor),
-            ActionType::InstantTethering => InstantTethering::supports_flavor(flavor),
-            ActionType::PhoneHub => PhoneHub::supports_flavor(flavor),
-        }
+        self.0 as u32
     }
 }
 
-// enabling an element for public adv requires privacy approval due to fingerprinting risk
-macros::boolean_element!(CrossDevSdk, 1, plaintext_and_ciphertext);
-macros::boolean_element!(CallTransfer, 4, ciphertext_only);
-macros::boolean_element!(ActiveUnlock, 8, ciphertext_only);
-macros::boolean_element!(NearbyShare, 9, plaintext_and_ciphertext);
-macros::boolean_element!(InstantTethering, 10, ciphertext_only);
-macros::boolean_element!(PhoneHub, 11, ciphertext_only);
+impl ActionElement for ActionType {
+    fn high_bit_index(&self) -> u32 {
+        self.high_bit_index()
+    }
+
+    fn bits(&self) -> u8 {
+        true as u8
+    }
+}
diff --git a/nearby/presence/np_adv/src/legacy/data_elements/actions/tests.rs b/nearby/presence/np_adv/src/legacy/data_elements/actions/tests.rs
index b96f94a..0f30215 100644
--- a/nearby/presence/np_adv/src/legacy/data_elements/actions/tests.rs
+++ b/nearby/presence/np_adv/src/legacy/data_elements/actions/tests.rs
@@ -30,21 +30,21 @@
 };
 use rand::seq::SliceRandom;
 use rand::Rng;
-use std::collections;
 use std::panic;
 use std::prelude::rust_2021::*;
 
+pub(crate) const CALL_TRANSFER: ActionType = ActionType(4);
+pub(crate) const ACTIVE_UNLOCK: ActionType = ActionType(8);
+pub(crate) const NEARBY_SHARE: ActionType = ActionType(9);
+pub(crate) const INSTANT_TETHERING: ActionType = ActionType(10);
+pub(crate) const PHONE_HUB: ActionType = ActionType(11);
+
 #[test]
 fn setting_action_only_changes_that_actions_bits() {
-    fn do_test<F: PacketFlavor>(
-        set_ones: impl Fn(ActionType, &mut ActionBits<F>),
-        set_zeros: impl Fn(ActionType, &mut ActionBits<F>),
-    ) {
-        for t in supported_action_types(F::ENUM_VARIANT) {
-            let other_types = supported_action_types(F::ENUM_VARIANT)
-                .into_iter()
-                .filter(|t2| *t2 != t)
-                .collect::<Vec<_>>();
+    fn do_test<F: PacketFlavor>(set_ones: impl Fn(ActionType, &mut ActionBits<F>)) {
+        for t in supported_action_types() {
+            let other_types =
+                supported_action_types().into_iter().filter(|t2| *t2 != t).collect::<Vec<_>>();
 
             let mut actions = ActionBits::<F>::default();
             set_ones(t, &mut actions);
@@ -59,35 +59,17 @@
             for &t2 in &other_types {
                 assert_eq!(0, actions.bits_for_type(t2))
             }
-
-            // now check that unsetting works
-            actions.bits = u32::MAX;
-            set_zeros(t, &mut actions);
-
-            assert_eq!(!t.all_bits(), actions.as_u32());
-            assert_eq!(0, actions.bits_for_type(t));
-            assert!(!actions.has_action(t));
-            // other types are set
-            for &t2 in &other_types {
-                assert_eq!(t2.all_bits() >> (31 - t2.high_bit_index()), actions.bits_for_type(t2));
-            }
         }
     }
 
-    do_test(
-        |t, bits| set_plaintext_action(t, true, bits),
-        |t, bits| set_plaintext_action(t, false, bits),
-    );
-    do_test(
-        |t, bits| set_ciphertexttext_action(t, true, bits),
-        |t, bits| set_ciphertexttext_action(t, false, bits),
-    );
+    do_test(|t, bits: &mut ActionBits<Plaintext>| set_action(t, bits));
+    do_test(|t, bits: &mut ActionBits<Ciphertext>| set_action(t, bits));
 }
 
 #[test]
 fn random_combos_of_actions_have_correct_bits_set() {
     fn do_test<F: PacketFlavor>(set_ones: impl Fn(ActionType, &mut ActionBits<F>)) {
-        let all_types = supported_action_types(F::ENUM_VARIANT);
+        let all_types = supported_action_types();
         let mut rng = rand::thread_rng();
 
         for _ in 0..1000 {
@@ -112,8 +94,8 @@
         }
     }
 
-    do_test::<Plaintext>(|t, bits| set_plaintext_action(t, true, bits));
-    do_test::<Ciphertext>(|t, bits| set_ciphertexttext_action(t, true, bits));
+    do_test::<Plaintext>(set_action);
+    do_test::<Ciphertext>(set_action);
 }
 
 #[test]
@@ -154,7 +136,7 @@
     // Special-case: All-zeroes should lead to a single byte being used.
     assert_eq!(1, actions.bytes_used());
 
-    actions.set_action(NearbyShare::from(true));
+    actions.set_action(NEARBY_SHARE);
     assert_eq!(2, actions.bytes_used());
 
     actions.set_action(LastBit::from(true));
@@ -201,11 +183,12 @@
     let mut action = all_plaintext_actions_set();
     action.set_action(LastBit::from(true));
 
-    // byte 0: cross dev sdk = 1
-    // byte 1: nearby share
-    // byte 2: last bit
+    // byte 0: actions 0 through 7
+    // byte 1: action 8 through 15
+    // byte 2: actions 16 through 23
+    // byte 2: actions 24 through 31
     assert_eq!(
-        &[actions_de_header_byte(3), 0x40, 0x40, 0x01],
+        &[actions_de_header_byte(4), 0xFF, 0xFF, 0xFF, 0xFF],
         serialize(&ActionsDataElement::<Plaintext>::from(action)).as_slice()
     );
 }
@@ -215,11 +198,12 @@
     let mut action = all_ciphertext_actions_set();
     action.set_action(LastBit::from(true));
 
-    // byte 1: cross dev sdk = 1, call transfer = 4
-    // byte 2: active unlock, nearby share, instant tethering, phone hub,
-    // byte 3: last bit
+    // byte 0: actions 0 through 7
+    // byte 1: action 8 through 15
+    // byte 2: actions 16 through 23
+    // byte 2: actions 24 through 31
     assert_eq!(
-        &[actions_de_header_byte(3), 0x48, 0xF0, 0x01],
+        &[actions_de_header_byte(4), 0xFF, 0xFF, 0xFF, 0xFF],
         serialize(&ActionsDataElement::<Ciphertext>::from(action)).as_slice()
     );
 }
@@ -231,7 +215,7 @@
         F: PacketFlavor,
         ActionsDataElement<F>: DeserializeDataElement,
     {
-        let all_types = supported_action_types(F::ENUM_VARIANT);
+        let all_types = supported_action_types();
         let mut rng = rand::thread_rng();
 
         for _ in 0..1000 {
@@ -253,79 +237,17 @@
         }
     }
 
-    do_test::<Plaintext>(|t, bits| set_plaintext_action(t, true, bits));
-    do_test::<Ciphertext>(|t, bits| set_ciphertexttext_action(t, true, bits));
-}
-
-#[test]
-fn action_element_bits_dont_overlap() {
-    let type_to_bits =
-        ActionType::iter().map(|t| (t, t.all_bits())).collect::<collections::HashMap<_, _>>();
-
-    for t in ActionType::iter() {
-        let bits = type_to_bits.get(&t).unwrap();
-
-        for (_, other_bits) in type_to_bits.iter().filter(|(other_type, _)| t != **other_type) {
-            assert_eq!(0, bits & other_bits, "type {t:?}");
-        }
-    }
+    do_test::<Plaintext>(set_action);
+    do_test::<Ciphertext>(set_action);
 }
 
 #[test]
 fn action_type_all_bits_masks() {
-    assert_eq!(0x08000000, ActionType::CallTransfer.all_bits());
-    assert_eq!(0x00800000, ActionType::ActiveUnlock.all_bits());
-    assert_eq!(0x00400000, ActionType::NearbyShare.all_bits());
-    assert_eq!(0x00200000, ActionType::InstantTethering.all_bits());
-    assert_eq!(0x00100000, ActionType::PhoneHub.all_bits());
-}
-
-#[test]
-fn action_type_all_bits_in_per_type_masks() {
-    for t in supported_action_types(PacketFlavorEnum::Plaintext) {
-        assert_eq!(t.all_bits(), t.all_bits() & *ALL_PLAINTEXT_ELEMENT_BITS);
-    }
-    for t in supported_action_types(PacketFlavorEnum::Ciphertext) {
-        assert_eq!(t.all_bits(), t.all_bits() & *ALL_CIPHERTEXT_ELEMENT_BITS);
-    }
-}
-
-#[test]
-fn action_bits_try_from_flavor_mismatch_plaintext() {
-    assert_eq!(
-        FlavorNotSupported { flavor: PacketFlavorEnum::Plaintext },
-        ActionBits::<Plaintext>::try_from(ActionType::CallTransfer.all_bits()).unwrap_err()
-    );
-}
-
-#[test]
-fn actions_de_deser_plaintext_with_ciphertext_action() {
-    assert_eq!(
-        DataElementDeserializeError::FlavorNotSupported {
-            de_type: ActionsDataElement::<Plaintext>::DE_TYPE_CODE,
-            flavor: PacketFlavorEnum::Plaintext,
-        },
-        <ActionsDataElement<Plaintext> as DeserializeDataElement>::deserialize::<Plaintext>(&[
-            // active unlock bit set
-            0x00, 0x80, 0x00,
-        ])
-        .unwrap_err()
-    );
-}
-
-#[test]
-fn actions_de_deser_ciphertext_with_plaintext_action() {
-    assert_eq!(
-        DataElementDeserializeError::FlavorNotSupported {
-            de_type: ActionsDataElement::<Plaintext>::DE_TYPE_CODE,
-            flavor: PacketFlavorEnum::Ciphertext,
-        },
-        <ActionsDataElement<Ciphertext> as DeserializeDataElement>::deserialize::<Ciphertext>(&[
-            // Finder bit set
-            0x00, 0x00, 0x80,
-        ])
-        .unwrap_err()
-    );
+    assert_eq!(0x08000000, CALL_TRANSFER.all_bits());
+    assert_eq!(0x00800000, ACTIVE_UNLOCK.all_bits());
+    assert_eq!(0x00400000, NEARBY_SHARE.all_bits());
+    assert_eq!(0x00200000, INSTANT_TETHERING.all_bits());
+    assert_eq!(0x00100000, PHONE_HUB.all_bits());
 }
 
 #[test]
@@ -438,22 +360,22 @@
 #[test]
 fn has_action_plaintext_works() {
     let mut action_bits = ActionBits::<Plaintext>::default();
-    action_bits.set_action(NearbyShare::from(true));
+    action_bits.set_action(NEARBY_SHARE);
     let action_de = ActionsDataElement::from(action_bits);
-    assert!(action_de.action.has_action(ActionType::NearbyShare));
-    assert!(!action_de.action.has_action(ActionType::ActiveUnlock));
-    assert!(!action_de.action.has_action(ActionType::PhoneHub));
+    assert!(action_de.action.has_action(NEARBY_SHARE));
+    assert!(!action_de.action.has_action(ACTIVE_UNLOCK));
+    assert!(!action_de.action.has_action(PHONE_HUB));
 }
 
 #[test]
 fn has_action_encrypted_works() {
     let mut action_bits = ActionBits::<Ciphertext>::default();
-    action_bits.set_action(NearbyShare::from(true));
-    action_bits.set_action(ActiveUnlock::from(true));
+    action_bits.set_action(NEARBY_SHARE);
+    action_bits.set_action(ACTIVE_UNLOCK);
     let action_de = ActionsDataElement::from(action_bits);
-    assert!(action_de.action.has_action(ActionType::NearbyShare));
-    assert!(action_de.action.has_action(ActionType::ActiveUnlock));
-    assert!(!action_de.action.has_action(ActionType::PhoneHub));
+    assert!(action_de.action.has_action(NEARBY_SHARE));
+    assert!(action_de.action.has_action(ACTIVE_UNLOCK));
+    assert!(!action_de.action.has_action(PHONE_HUB));
 }
 
 #[test]
@@ -498,6 +420,9 @@
 }
 
 mod coverage_gaming {
+    use crate::legacy::data_elements::actions::tests::{
+        ACTIVE_UNLOCK, CALL_TRANSFER, INSTANT_TETHERING, NEARBY_SHARE, PHONE_HUB,
+    };
     use crate::legacy::data_elements::actions::*;
     use crate::legacy::Plaintext;
     use alloc::format;
@@ -505,7 +430,7 @@
     #[test]
     fn actions_de_debug() {
         let actions = ActionsDataElement::<Plaintext>::from(ActionBits::default());
-        let _ = format!("{:?}", actions);
+        let _ = format!("{actions:?}");
     }
 
     #[test]
@@ -515,16 +440,16 @@
 
     #[test]
     fn action_type_clone_debug() {
-        let _ = format!("{:?}", ActionType::CallTransfer.clone());
+        let _ = format!("{:?}", CALL_TRANSFER.clone());
     }
 
     #[test]
     fn actions_debug() {
-        let _ = format!("{:?}", CallTransfer::from(true));
-        let _ = format!("{:?}", ActiveUnlock::from(true));
-        let _ = format!("{:?}", NearbyShare::from(true));
-        let _ = format!("{:?}", InstantTethering::from(true));
-        let _ = format!("{:?}", PhoneHub::from(true));
+        let _ = format!("{CALL_TRANSFER:?}");
+        let _ = format!("{ACTIVE_UNLOCK:?}");
+        let _ = format!("{NEARBY_SHARE:?}");
+        let _ = format!("{INSTANT_TETHERING:?}");
+        let _ = format!("{PHONE_HUB:?}");
     }
 }
 
@@ -541,21 +466,14 @@
 }
 
 impl ActionElement for FirstBit {
-    const HIGH_BIT_INDEX: u32 = 0;
-    // don't want to add a variant for this test only type
-    const ACTION_TYPE: ActionType = ActionType::ActiveUnlock;
-
-    fn supports_flavor(_flavor: PacketFlavorEnum) -> bool {
-        true
-    }
-
     fn bits(&self) -> u8 {
         self.enabled as u8
     }
-}
 
-macros::boolean_element_to_plaintext_element!(FirstBit);
-macros::boolean_element_to_encrypted_element!(FirstBit);
+    fn high_bit_index(&self) -> u32 {
+        0
+    }
+}
 
 // hypothetical action using the last bit
 #[derive(Debug)]
@@ -570,21 +488,14 @@
 }
 
 impl ActionElement for LastBit {
-    const HIGH_BIT_INDEX: u32 = 23;
-    // don't want to add a variant for this test only type
-    const ACTION_TYPE: ActionType = ActionType::ActiveUnlock;
-
-    fn supports_flavor(_flavor: PacketFlavorEnum) -> bool {
-        true
-    }
-
     fn bits(&self) -> u8 {
         self.enabled as u8
     }
-}
 
-macros::boolean_element_to_plaintext_element!(LastBit);
-macros::boolean_element_to_encrypted_element!(LastBit);
+    fn high_bit_index(&self) -> u32 {
+        23
+    }
+}
 
 // An action that only supports plaintext, to allow testing that error case
 pub(in crate::legacy) struct PlaintextOnly {
@@ -598,25 +509,14 @@
 }
 
 impl ActionElement for PlaintextOnly {
-    const HIGH_BIT_INDEX: u32 = 22;
-
-    const ACTION_TYPE: ActionType = ActionType::ActiveUnlock;
-
-    fn supports_flavor(flavor: PacketFlavorEnum) -> bool {
-        match flavor {
-            PacketFlavorEnum::Plaintext => true,
-            PacketFlavorEnum::Ciphertext => false,
-        }
-    }
-
     fn bits(&self) -> u8 {
         self.enabled as u8
     }
-}
 
-macros::boolean_element_to_plaintext_element!(PlaintextOnly);
-// sneakily allow serializing it, but deserializing will fail due to supports_flavor above
-macros::boolean_element_to_encrypted_element!(PlaintextOnly);
+    fn high_bit_index(&self) -> u32 {
+        22
+    }
+}
 
 fn assert_eq_hex(expected: u32, actual: u32) {
     assert_eq!(expected, actual, "{expected:#010X} != {actual:#010X}");
@@ -624,34 +524,24 @@
 
 pub(crate) fn all_plaintext_actions_set() -> ActionBits<Plaintext> {
     let mut action = ActionBits::default();
-    action.set_action(CrossDevSdk::from(true));
-    action.set_action(NearbyShare::from(true));
-
-    assert!(supported_action_types(PacketFlavorEnum::Plaintext)
-        .into_iter()
-        .all(|t| t.all_bits() & action.bits != 0));
+    for a in supported_action_types() {
+        action.set_action(a);
+    }
 
     action
 }
 
 pub(crate) fn all_ciphertext_actions_set() -> ActionBits<Ciphertext> {
     let mut action = ActionBits::default();
-    action.set_action(CrossDevSdk::from(true));
-    action.set_action(CallTransfer::from(true));
-    action.set_action(ActiveUnlock::from(true));
-    action.set_action(NearbyShare::from(true));
-    action.set_action(InstantTethering::from(true));
-    action.set_action(PhoneHub::from(true));
-
-    assert!(supported_action_types(PacketFlavorEnum::Ciphertext)
-        .into_iter()
-        .all(|t| t.all_bits() & action.bits != 0));
+    for a in supported_action_types() {
+        action.set_action(a);
+    }
 
     action
 }
 
-fn supported_action_types(flavor: PacketFlavorEnum) -> Vec<ActionType> {
-    ActionType::iter().filter(|t| t.supports_flavor(flavor)).collect()
+pub(crate) fn supported_action_types() -> Vec<ActionType> {
+    (0..ActionType::MAX_ACTION_ID + 1).map(ActionType).collect()
 }
 
 /// Encode a DE header byte with the provided type and actual len, transforming into an encoded
@@ -663,28 +553,6 @@
     )
 }
 
-pub(crate) fn set_plaintext_action(t: ActionType, value: bool, bits: &mut ActionBits<Plaintext>) {
-    match t {
-        ActionType::CrossDevSdk => bits.set_action(CrossDevSdk::from(value)),
-        ActionType::NearbyShare => bits.set_action(NearbyShare::from(value)),
-        ActionType::CallTransfer
-        | ActionType::PhoneHub
-        | ActionType::ActiveUnlock
-        | ActionType::InstantTethering => panic!(),
-    }
-}
-
-pub(crate) fn set_ciphertexttext_action(
-    t: ActionType,
-    value: bool,
-    bits: &mut ActionBits<Ciphertext>,
-) {
-    match t {
-        ActionType::CrossDevSdk => bits.set_action(CrossDevSdk::from(value)),
-        ActionType::CallTransfer => bits.set_action(CallTransfer::from(value)),
-        ActionType::ActiveUnlock => bits.set_action(ActiveUnlock::from(value)),
-        ActionType::NearbyShare => bits.set_action(NearbyShare::from(value)),
-        ActionType::InstantTethering => bits.set_action(InstantTethering::from(value)),
-        ActionType::PhoneHub => bits.set_action(PhoneHub::from(value)),
-    }
+pub(crate) fn set_action<T: PacketFlavor>(t: ActionType, bits: &mut ActionBits<T>) {
+    bits.set_action(t);
 }
diff --git a/nearby/presence/np_adv/src/legacy/data_elements/de_type/mod.rs b/nearby/presence/np_adv/src/legacy/data_elements/de_type/mod.rs
index e5dd153..f239690 100644
--- a/nearby/presence/np_adv/src/legacy/data_elements/de_type/mod.rs
+++ b/nearby/presence/np_adv/src/legacy/data_elements/de_type/mod.rs
@@ -124,4 +124,6 @@
 pub(in crate::legacy) enum DataElementType {
     TxPower,
     Actions,
+    Psm,
+    DeviceInfo,
 }
diff --git a/nearby/presence/np_adv/src/legacy/data_elements/device_info.rs b/nearby/presence/np_adv/src/legacy/data_elements/device_info.rs
new file mode 100644
index 0000000..0a9eef1
--- /dev/null
+++ b/nearby/presence/np_adv/src/legacy/data_elements/device_info.rs
@@ -0,0 +1,112 @@
+// Copyright 2022 Google LLC
+//
+// 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
+//
+//      http://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.
+
+//! Data element for Device Info.
+
+use crate::legacy::data_elements::de_type::{DeActualLength, DeEncodedLength, DeTypeCode};
+use crate::legacy::data_elements::{
+    DataElementDeserializeError, DataElementSerializationBuffer, DataElementSerializeError,
+    DeserializeDataElement, SerializeDataElement,
+};
+use crate::legacy::PacketFlavor;
+use crate::private::Sealed;
+use crate::shared_data::{DeviceInfo, DeviceType};
+use sink::Sink;
+
+/// Data element holding a [DeviceInfo].
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct DeviceInfoDataElement {
+    /// The device info value
+    pub device_info: DeviceInfo,
+}
+
+impl From<DeviceInfo> for DeviceInfoDataElement {
+    fn from(device_info: DeviceInfo) -> Self {
+        Self { device_info }
+    }
+}
+
+impl Sealed for DeviceInfoDataElement {}
+
+impl<F: PacketFlavor> SerializeDataElement<F> for DeviceInfoDataElement {
+    fn de_type_code(&self) -> DeTypeCode {
+        DeviceInfoDataElement::DE_TYPE_CODE
+    }
+
+    fn map_actual_len_to_encoded_len(&self, actual_len: DeActualLength) -> DeEncodedLength {
+        // V0 length is just the device name length + 1 for the type/truncation byte.
+        <Self as DeserializeDataElement>::LengthMapper::map_actual_len_to_encoded_len(actual_len)
+    }
+
+    fn serialize_contents(
+        &self,
+        sink: &mut DataElementSerializationBuffer,
+    ) -> Result<(), DataElementSerializeError> {
+        let mut type_and_trunc = self.device_info.device_type() as u8;
+        if self.device_info.name_truncated() {
+            type_and_trunc |= 0b10000000;
+        }
+        sink.try_push(type_and_trunc)
+            .and_then(|_| sink.try_extend_from_slice(self.device_info.device_name()))
+            .ok_or(DataElementSerializeError::InsufficientSpace)
+    }
+}
+
+impl DeserializeDataElement for DeviceInfoDataElement {
+    const DE_TYPE_CODE: DeTypeCode = match DeTypeCode::try_from(0b0011) {
+        Ok(t) => t,
+        Err(_) => unreachable!(),
+    };
+
+    type LengthMapper = DeviceInfoLengthMapper;
+
+    fn deserialize<F: PacketFlavor>(
+        de_contents: &[u8],
+    ) -> Result<Self, DataElementDeserializeError> {
+        if de_contents.len() < 6 {
+            return Err(DataElementDeserializeError::DeserializeError {
+                de_type: Self::DE_TYPE_CODE,
+            });
+        }
+
+        let type_and_trunc = de_contents[0];
+        let device_type = DeviceType::from_repr(type_and_trunc & 0b01111111)
+            .ok_or(DataElementDeserializeError::DeserializeError { de_type: Self::DE_TYPE_CODE })?;
+        let name_truncated = (type_and_trunc & 0b10000000) != 0;
+        let device_name = &de_contents[1..];
+
+        let device_info = DeviceInfo::try_from((device_type, name_truncated, device_name))
+            .map_err(|_| DataElementDeserializeError::DeserializeError {
+                de_type: Self::DE_TYPE_CODE,
+            })?;
+
+        Ok(device_info.into())
+    }
+}
+
+pub(in crate::legacy) struct DeviceInfoLengthMapper;
+
+use crate::legacy::data_elements::{DeLengthOutOfRange, LengthMapper};
+impl LengthMapper for DeviceInfoLengthMapper {
+    fn map_actual_len_to_encoded_len(actual_len: DeActualLength) -> DeEncodedLength {
+        DeEncodedLength::try_from(actual_len.as_u8())
+            .expect("Broken DE implementation produced invalid length.")
+    }
+
+    fn map_encoded_len_to_actual_len(
+        encoded_len: DeEncodedLength,
+    ) -> Result<DeActualLength, DeLengthOutOfRange> {
+        DeActualLength::try_from(encoded_len.as_u8() as usize)
+    }
+}
diff --git a/nearby/presence/np_adv/src/legacy/data_elements/mod.rs b/nearby/presence/np_adv/src/legacy/data_elements/mod.rs
index c952d08..ace5f7b 100644
--- a/nearby/presence/np_adv/src/legacy/data_elements/mod.rs
+++ b/nearby/presence/np_adv/src/legacy/data_elements/mod.rs
@@ -29,6 +29,8 @@
 
 pub mod actions;
 pub mod de_type;
+pub mod device_info;
+pub mod psm;
 pub mod tx_power;
 
 #[cfg(test)]
diff --git a/nearby/presence/np_adv/src/legacy/data_elements/psm.rs b/nearby/presence/np_adv/src/legacy/data_elements/psm.rs
new file mode 100644
index 0000000..4fc1393
--- /dev/null
+++ b/nearby/presence/np_adv/src/legacy/data_elements/psm.rs
@@ -0,0 +1,172 @@
+// Copyright 2025 Google LLC
+//
+// 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
+//
+//      http://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.
+
+//! Data element for PSM.
+
+use crate::legacy::data_elements::de_type::{DeActualLength, DeEncodedLength, DeTypeCode};
+use crate::legacy::data_elements::{
+    DataElementDeserializeError, DataElementSerializationBuffer, DataElementSerializeError,
+    DeserializeDataElement, DirectMapPredicate, DirectMapper, LengthMapper, SerializeDataElement,
+};
+use crate::legacy::PacketFlavor;
+use crate::private::Sealed;
+use sink::Sink;
+
+/// Data element holding a PSM.
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct PsmDataElement {
+    /// The two bytes PSM value.
+    pub value: u16,
+}
+
+impl Sealed for crate::legacy::data_elements::psm::PsmDataElement {}
+
+impl<F: PacketFlavor> SerializeDataElement<F>
+    for crate::legacy::data_elements::psm::PsmDataElement
+{
+    fn de_type_code(&self) -> DeTypeCode {
+        crate::legacy::data_elements::psm::PsmDataElement::DE_TYPE_CODE
+    }
+
+    fn map_actual_len_to_encoded_len(&self, actual_len: DeActualLength) -> DeEncodedLength {
+        <Self as DeserializeDataElement>::LengthMapper::map_actual_len_to_encoded_len(actual_len)
+    }
+
+    fn serialize_contents(
+        &self,
+        sink: &mut DataElementSerializationBuffer,
+    ) -> Result<(), DataElementSerializeError> {
+        sink.try_extend_from_slice(self.value.to_be_bytes().as_slice())
+            .ok_or(DataElementSerializeError::InsufficientSpace)
+    }
+}
+
+impl DeserializeDataElement for crate::legacy::data_elements::psm::PsmDataElement {
+    const DE_TYPE_CODE: DeTypeCode = match DeTypeCode::try_from(0b0100) {
+        Ok(t) => t,
+        Err(_) => unreachable!(),
+    };
+
+    type LengthMapper = DirectMapper<PsmLengthPredicate>;
+
+    fn deserialize<F: PacketFlavor>(
+        de_contents: &[u8],
+    ) -> Result<Self, DataElementDeserializeError> {
+        de_contents
+            .try_into()
+            .ok()
+            .map(|arr: [u8; 2]| Self { value: u16::from_be_bytes(arr) })
+            .ok_or(DataElementDeserializeError::DeserializeError { de_type: Self::DE_TYPE_CODE })
+    }
+}
+
+pub(in crate::legacy) struct PsmLengthPredicate;
+
+impl DirectMapPredicate for PsmLengthPredicate {
+    /// PSM is 2 bytes value.
+    fn is_valid(len: usize) -> bool {
+        len == 2
+    }
+}
+
+#[allow(clippy::unwrap_used)]
+#[cfg(test)]
+mod tests {
+    use crate::legacy::data_elements::de_type::{DeActualLength, DeEncodedLength};
+    use crate::legacy::data_elements::psm::PsmDataElement;
+    use crate::legacy::data_elements::tests::macros::de_roundtrip_test;
+    use crate::legacy::data_elements::{DeserializeDataElement, LengthMapper};
+    use crate::legacy::serialize::tests::serialize;
+    use crate::legacy::{Ciphertext, Plaintext};
+    use crate::DeLengthOutOfRange;
+    use std::panic;
+
+    extern crate std;
+
+    #[test]
+    fn actual_length_must_be_2() {
+        for l in [0, 1, 3] {
+            let actual = DeActualLength::try_from(l).unwrap();
+            let _ = panic::catch_unwind(|| {
+                <PsmDataElement as DeserializeDataElement>::LengthMapper::map_actual_len_to_encoded_len(actual)
+            }).unwrap_err();
+        }
+
+        assert_eq!(
+            2,
+            <PsmDataElement as DeserializeDataElement>::LengthMapper::map_actual_len_to_encoded_len(
+                DeActualLength::try_from(2).unwrap(),
+            )
+                .as_u8()
+        )
+    }
+
+    #[test]
+    fn encoded_length_must_be_2() {
+        for l in [0, 1, 3] {
+            assert_eq!(
+                DeLengthOutOfRange,
+                <PsmDataElement as DeserializeDataElement>::LengthMapper::map_encoded_len_to_actual_len(
+                    DeEncodedLength::try_from(l).unwrap()
+                )
+                    .unwrap_err()
+            )
+        }
+
+        assert_eq!(
+            2,
+            <PsmDataElement as DeserializeDataElement>::LengthMapper::map_encoded_len_to_actual_len(
+                DeEncodedLength::from(2)
+            )
+                .unwrap()
+                .as_u8()
+        );
+    }
+
+    #[test]
+    fn dedup_hint_de_contents_roundtrip_unencrypted() {
+        let _ = de_roundtrip_test!(
+            PsmDataElement,
+            Psm,
+            Psm,
+            Plaintext,
+            serialize::<Plaintext, _>(&PsmDataElement { value: 0x10 })
+        );
+    }
+
+    #[test]
+    fn psm_de_contents_roundtrip_ldt() {
+        let _ = de_roundtrip_test!(
+            PsmDataElement,
+            Psm,
+            Psm,
+            Ciphertext,
+            serialize::<Ciphertext, _>(&PsmDataElement { value: 0x10 })
+        );
+    }
+
+    mod coverage_gaming {
+        use crate::legacy::data_elements::psm::PsmDataElement;
+        use alloc::format;
+
+        #[test]
+        fn psm_de() {
+            let de = PsmDataElement { value: 0x10 };
+            // debug
+            let _ = format!("{de:?}");
+            // trivial accessor
+            assert_eq!(0x10, de.value);
+        }
+    }
+}
diff --git a/nearby/presence/np_adv/src/legacy/data_elements/tests.rs b/nearby/presence/np_adv/src/legacy/data_elements/tests.rs
index a5e8fbf..ff3a891 100644
--- a/nearby/presence/np_adv/src/legacy/data_elements/tests.rs
+++ b/nearby/presence/np_adv/src/legacy/data_elements/tests.rs
@@ -99,6 +99,8 @@
             match de {
                 DeserializedDataElement::Actions(_) => DataElementType::Actions,
                 DeserializedDataElement::TxPower(_) => DataElementType::TxPower,
+                DeserializedDataElement::Psm(_) => DataElementType::Psm,
+                DeserializedDataElement::DeviceInfo(_) => DataElementType::DeviceInfo,
             }
         );
         de
diff --git a/nearby/presence/np_adv/src/legacy/data_elements/tx_power.rs b/nearby/presence/np_adv/src/legacy/data_elements/tx_power.rs
index e8c787e..b3ae27f 100644
--- a/nearby/presence/np_adv/src/legacy/data_elements/tx_power.rs
+++ b/nearby/presence/np_adv/src/legacy/data_elements/tx_power.rs
@@ -181,7 +181,7 @@
         fn tx_power_de() {
             let de = TxPowerDataElement::from(TxPower::try_from(3).unwrap());
             // debug
-            let _ = format!("{:?}", de);
+            let _ = format!("{de:?}");
             // trivial accessor
             assert_eq!(3, de.tx_power_value());
         }
diff --git a/nearby/presence/np_adv/src/legacy/deserialize/mod.rs b/nearby/presence/np_adv/src/legacy/deserialize/mod.rs
index 075c0c9..0c61066 100644
--- a/nearby/presence/np_adv/src/legacy/deserialize/mod.rs
+++ b/nearby/presence/np_adv/src/legacy/deserialize/mod.rs
@@ -44,6 +44,8 @@
 use crate::credential::matched::HasIdentityMatch;
 use crate::legacy::data_elements::actions::ActionsDataElement;
 use crate::legacy::data_elements::de_type::{DataElementType, DeActualLength};
+use crate::legacy::data_elements::device_info::DeviceInfoDataElement;
+use crate::legacy::data_elements::psm::PsmDataElement;
 use crate::legacy::data_elements::DataElementDeserializeError::DuplicateDeTypes;
 use crate::legacy::Plaintext;
 /// exposed because the unencrypted case isn't just for intermediate: no further processing is needed
@@ -228,6 +230,8 @@
 pub enum DeserializedDataElement<F: PacketFlavor> {
     Actions(actions::ActionsDataElement<F>),
     TxPower(TxPowerDataElement),
+    Psm(PsmDataElement),
+    DeviceInfo(DeviceInfoDataElement),
 }
 
 impl<F: PacketFlavor> Deserialized for DeserializedDataElement<F> {
@@ -241,6 +245,14 @@
                 DeTypeCode::try_from(TxPowerDataElement::DE_TYPE_CODE.as_u8())
                     .expect("TxPower type code is valid so this will always succeed")
             }
+            DeserializedDataElement::Psm(_) => {
+                DeTypeCode::try_from(PsmDataElement::DE_TYPE_CODE.as_u8())
+                    .expect("Psm type code is valid so this will always succeed")
+            }
+            DeserializedDataElement::DeviceInfo(_) => {
+                DeTypeCode::try_from(DeviceInfoDataElement::DE_TYPE_CODE.as_u8())
+                    .expect("DeviceInfo type code is valid so this will always succeed")
+            }
         }
     }
 }
@@ -252,6 +264,8 @@
         match self {
             DeserializedDataElement::Actions(_) => ActionsDataElement::<F>::DE_TYPE_CODE.as_u8(),
             DeserializedDataElement::TxPower(_) => TxPowerDataElement::DE_TYPE_CODE.as_u8(),
+            DeserializedDataElement::Psm(_) => PsmDataElement::DE_TYPE_CODE.as_u8(),
+            DeserializedDataElement::DeviceInfo(_) => DeviceInfoDataElement::DE_TYPE_CODE.as_u8(),
         }
     }
 
@@ -270,6 +284,12 @@
             DeserializedDataElement::TxPower(t) => {
                 SerializeDataElement::<F>::serialize_contents(t, &mut sink)
             }
+            DeserializedDataElement::Psm(h) => {
+                SerializeDataElement::<F>::serialize_contents(h, &mut sink)
+            }
+            DeserializedDataElement::DeviceInfo(d) => {
+                SerializeDataElement::<F>::serialize_contents(d, &mut sink)
+            }
         }
         .unwrap();
         sink.into_inner().into_inner().as_slice().to_vec()
@@ -392,6 +412,17 @@
                     .map_err(|e| e.into())
                     .map(|l| (DataElementType::Actions, l))
             }
+            PsmDataElement::DE_TYPE_CODE => {
+                <PsmDataElement as DeserializeDataElement>::LengthMapper::map_encoded_len_to_actual_len(encoded_len)
+                    .map_err(|e| e.into())
+                    .map(|l| (DataElementType::Psm, l))
+
+            }
+            DeviceInfoDataElement::DE_TYPE_CODE => {
+                <DeviceInfoDataElement as DeserializeDataElement>::LengthMapper::map_encoded_len_to_actual_len(encoded_len)
+                    .map_err(|e| e.into())
+                    .map(|l| (DataElementType::DeviceInfo, l))
+            }
             _ => Err(LengthError::InvalidType),
         }
     }
@@ -406,6 +437,11 @@
             }
             DataElementType::TxPower => TxPowerDataElement::deserialize::<F>(raw_de.contents)
                 .map(DeserializedDataElement::TxPower),
+            DataElementType::Psm => {
+                PsmDataElement::deserialize::<F>(raw_de.contents).map(DeserializedDataElement::Psm)
+            }
+            DataElementType::DeviceInfo => DeviceInfoDataElement::deserialize::<F>(raw_de.contents)
+                .map(DeserializedDataElement::DeviceInfo),
         }
     }
 }
diff --git a/nearby/presence/np_adv/src/legacy/deserialize/tests/error_conditions.rs b/nearby/presence/np_adv/src/legacy/deserialize/tests/error_conditions.rs
index fe5ec21..fbc5511 100644
--- a/nearby/presence/np_adv/src/legacy/deserialize/tests/error_conditions.rs
+++ b/nearby/presence/np_adv/src/legacy/deserialize/tests/error_conditions.rs
@@ -15,13 +15,12 @@
 mod unencrypted {
 
     use crate::header::V0Encoding;
-    use crate::legacy::data_elements::actions::{ActionBits, ActionsDataElement, ActiveUnlock};
+    use crate::legacy::data_elements::actions::ActionsDataElement;
     use crate::legacy::data_elements::de_type::{DeEncodedLength, DeTypeCode};
     use crate::legacy::data_elements::tx_power::TxPowerDataElement;
     use crate::legacy::data_elements::{DataElementDeserializeError, DeserializeDataElement};
     use crate::legacy::deserialize::intermediate::IntermediateAdvContents;
-    use crate::legacy::serialize::tests::serialize;
-    use crate::legacy::{Ciphertext, PacketFlavorEnum, Plaintext};
+    use crate::legacy::Plaintext;
 
     #[test]
     fn iterate_tx_power_invalid_de_len_error() {
@@ -59,19 +58,6 @@
     }
 
     #[test]
-    fn iterate_actions_ciphertext_only_bit_error() {
-        let mut bits = ActionBits::default();
-        bits.set_action(ActiveUnlock::from(true));
-        assert_deser_error(
-            serialize(&ActionsDataElement::<Ciphertext>::from(bits)).as_slice(),
-            DataElementDeserializeError::FlavorNotSupported {
-                de_type: ActionsDataElement::<Plaintext>::DE_TYPE_CODE,
-                flavor: PacketFlavorEnum::Plaintext,
-            },
-        );
-    }
-
-    #[test]
     fn iterate_invalid_de_type_error() {
         assert_deser_error(
             &[0x0F],
@@ -123,17 +109,12 @@
     // see unencrypted tests above for basic things that are the same between unencrypted and ldt,
     // like how an invalid de type is handled
 
-    use crate::credential::matched::HasIdentityMatch;
     use crate::credential::v0::V0BroadcastCredential;
     use crate::header::V0Encoding;
-    use crate::legacy::data_elements::actions::tests::PlaintextOnly;
-    use crate::legacy::data_elements::actions::{ActionBits, ActionsDataElement};
     use crate::legacy::data_elements::tx_power::TxPowerDataElement;
-    use crate::legacy::data_elements::{DataElementDeserializeError, DeserializeDataElement};
     use crate::legacy::deserialize::intermediate::IntermediateAdvContents;
     use crate::legacy::deserialize::DecryptError;
     use crate::legacy::serialize::{AdvBuilder, LdtEncoder};
-    use crate::legacy::{Ciphertext, PacketFlavorEnum};
     use crate::shared_data::TxPower;
     use alloc::vec::Vec;
     use crypto_provider_default::CryptoProviderImpl;
@@ -143,45 +124,6 @@
     };
 
     #[test]
-    fn iterate_actions_invalid_flavor_error() {
-        let mut bits = ActionBits::default();
-        bits.set_action(PlaintextOnly::from(true));
-
-        let key_seed = [0; 32];
-        let identity_token = V0IdentityToken::from([0x33; V0_IDENTITY_TOKEN_LEN]);
-        let salt = V0Salt::from([0x01, 0x02]);
-        let broadcast_cred = V0BroadcastCredential::new(key_seed, identity_token);
-        let mut builder =
-            AdvBuilder::new(LdtEncoder::<CryptoProviderImpl>::new(salt, &broadcast_cred));
-
-        builder.add_data_element(ActionsDataElement::from(bits)).unwrap();
-
-        let adv = builder.into_advertisement().unwrap();
-
-        let mut contents =
-            IntermediateAdvContents::deserialize(V0Encoding::Ldt, &adv.as_slice()[1..]).unwrap();
-        let ldt = contents.as_ldt().unwrap();
-        let hkdf = np_hkdf::NpKeySeedHkdf::<CryptoProviderImpl>::new(&key_seed);
-        let identity_token_hmac: [u8; 32] = hkdf
-            .v0_identity_token_hmac_key()
-            .calculate_hmac::<CryptoProviderImpl>(identity_token.as_slice());
-        let decrypter =
-            ldt_np_adv::build_np_adv_decrypter_from_key_seed(&hkdf, identity_token_hmac);
-        let decrypted = ldt.try_decrypt(&decrypter).unwrap();
-
-        assert_eq!(salt, decrypted.salt());
-        assert_eq!(identity_token, decrypted.identity_token());
-
-        assert_eq!(
-            DataElementDeserializeError::FlavorNotSupported {
-                de_type: ActionsDataElement::<Ciphertext>::DE_TYPE_CODE,
-                flavor: PacketFlavorEnum::Ciphertext,
-            },
-            decrypted.data_elements().next().unwrap().unwrap_err()
-        )
-    }
-
-    #[test]
     fn decrypter_wrong_identity_token_hmac_no_match() {
         build_and_deser_with_invalid_decrypter_error(
             |adv| adv,
diff --git a/nearby/presence/np_adv/src/legacy/deserialize/tests/happy_path.rs b/nearby/presence/np_adv/src/legacy/deserialize/tests/happy_path.rs
index 8b0f979..d857993 100644
--- a/nearby/presence/np_adv/src/legacy/deserialize/tests/happy_path.rs
+++ b/nearby/presence/np_adv/src/legacy/deserialize/tests/happy_path.rs
@@ -23,7 +23,7 @@
         header::V0Encoding,
         legacy::{
             data_elements::{
-                actions::{ActionBits, ActionsDataElement, NearbyShare},
+                actions::{tests::NEARBY_SHARE, ActionBits, ActionsDataElement},
                 de_type::DataElementType,
                 tests::test_des::{
                     random_test_de, TestDataElement, TestDataElementType, TestDeDeserializer,
@@ -37,8 +37,10 @@
             },
             random_data_elements::random_de_plaintext,
             serialize::{
-                tests::helpers::{LongDataElement, ShortDataElement},
-                tests::supports_flavor,
+                tests::{
+                    helpers::{LongDataElement, ShortDataElement},
+                    supports_flavor,
+                },
                 AddDataElementError, AdvBuilder, SerializedAdv, UnencryptedEncoder,
             },
             PacketFlavorEnum, Plaintext, BLE_4_ADV_SVC_MAX_CONTENT_LEN, NP_MAX_DE_CONTENT_LEN,
@@ -120,7 +122,7 @@
     fn typical_tx_power_and_actions() {
         let tx = TxPowerDataElement::from(TxPower::try_from(7).unwrap());
         let mut action_bits = ActionBits::default();
-        action_bits.set_action(NearbyShare::from(true));
+        action_bits.set_action(NEARBY_SHARE);
         let actions = ActionsDataElement::from(action_bits);
 
         let tx_boxed: Box<dyn SerializeDataElement<Plaintext>> = Box::new(tx.clone());
@@ -154,6 +156,8 @@
             |builder, de| match de {
                 DeserializedDataElement::Actions(a) => builder.add_data_element(a),
                 DeserializedDataElement::TxPower(tx) => builder.add_data_element(tx),
+                DeserializedDataElement::Psm(d) => builder.add_data_element(d),
+                DeserializedDataElement::DeviceInfo(d) => builder.add_data_element(d),
             },
         )
     }
@@ -253,14 +257,14 @@
 
 mod ldt {
     use crate::credential::matched::HasIdentityMatch;
-    use crate::legacy::data_elements::actions::CallTransfer;
+    use crate::legacy::data_elements::actions::tests::{CALL_TRANSFER, NEARBY_SHARE};
     use crate::legacy::data_elements::de_type::MAX_DE_ENCODED_LEN;
     use crate::{
         credential::v0::V0BroadcastCredential,
         header::V0Encoding,
         legacy::{
             data_elements::{
-                actions::{ActionBits, ActionsDataElement, NearbyShare},
+                actions::{ActionBits, ActionsDataElement},
                 de_type::DataElementType,
                 tests::test_des::TestDataElementType,
                 tests::test_des::{TestDataElement, TestDeDeserializer},
@@ -344,8 +348,8 @@
     fn typical_tx_power_and_actions() {
         let tx = TxPowerDataElement::from(TxPower::try_from(7).unwrap());
         let mut action_bits = ActionBits::default();
-        action_bits.set_action(NearbyShare::from(true));
-        action_bits.set_action(CallTransfer::from(true));
+        action_bits.set_action(NEARBY_SHARE);
+        action_bits.set_action(CALL_TRANSFER);
         let actions = ActionsDataElement::from(action_bits);
         let tx_boxed: Box<dyn SerializeDataElement<Ciphertext>> = Box::new(tx.clone());
         let actions_boxed: Box<dyn SerializeDataElement<Ciphertext>> = Box::new(actions.clone());
@@ -371,10 +375,14 @@
             |builder, de| match de {
                 DeserializedDataElement::Actions(a) => builder.add_data_element(a),
                 DeserializedDataElement::TxPower(tx) => builder.add_data_element(tx),
+                DeserializedDataElement::Psm(d) => builder.add_data_element(d),
+                DeserializedDataElement::DeviceInfo(d) => builder.add_data_element(d),
             },
             |de| match de {
                 DeserializedDataElement::Actions(a) => serialized_len(a),
                 DeserializedDataElement::TxPower(tx) => serialized_len(tx),
+                DeserializedDataElement::Psm(d) => serialized_len(d),
+                DeserializedDataElement::DeviceInfo(d) => serialized_len(d),
             },
         )
     }
@@ -530,7 +538,7 @@
     #[test]
     fn iac_debug_eq_test_helpers() {
         let iac = IntermediateAdvContents::deserialize(V0Encoding::Unencrypted, &[0xFF]).unwrap();
-        let _ = format!("{:?}", iac);
+        let _ = format!("{iac:?}");
         assert_eq!(iac, iac);
     }
 
@@ -562,7 +570,7 @@
             contents: &[],
         };
 
-        let _ = format!("{:?}", rde);
+        let _ = format!("{rde:?}");
         assert_eq!(rde, rde);
     }
 
@@ -576,6 +584,6 @@
     #[test]
     fn ldt_adv_contents_debug() {
         let lac = LdtAdvContents::new([0; 2].into(), &[0; 16]).unwrap();
-        let _ = format!("{:?}", lac);
+        let _ = format!("{lac:?}");
     }
 }
diff --git a/nearby/presence/np_adv/src/legacy/deserialize/tests/mod.rs b/nearby/presence/np_adv/src/legacy/deserialize/tests/mod.rs
index af85f9a..a9f2978 100644
--- a/nearby/presence/np_adv/src/legacy/deserialize/tests/mod.rs
+++ b/nearby/presence/np_adv/src/legacy/deserialize/tests/mod.rs
@@ -180,7 +180,7 @@
             [0; V0_SALT_LEN].into(),
             ArrayView::try_from_slice(&[]).unwrap(),
         );
-        let _ = format!("{:?}", dac);
+        let _ = format!("{dac:?}");
         assert_eq!(dac, dac)
     }
 
diff --git a/nearby/presence/np_adv/src/legacy/mod.rs b/nearby/presence/np_adv/src/legacy/mod.rs
index 0ea4675..4dd9078 100644
--- a/nearby/presence/np_adv/src/legacy/mod.rs
+++ b/nearby/presence/np_adv/src/legacy/mod.rs
@@ -137,7 +137,7 @@
         #[test]
         fn plaintext_flavor() {
             // debug
-            let _ = format!("{:?}", Plaintext);
+            let _ = format!("{Plaintext:?}");
             // eq and clone
             assert_eq!(Plaintext, Plaintext.clone())
         }
@@ -145,7 +145,7 @@
         #[test]
         fn ciphertext_flavor() {
             // debug
-            let _ = format!("{:?}", Ciphertext);
+            let _ = format!("{Ciphertext:?}");
             // eq and clone
             assert_eq!(Ciphertext, Ciphertext.clone())
         }
diff --git a/nearby/presence/np_adv/src/legacy/random_data_elements.rs b/nearby/presence/np_adv/src/legacy/random_data_elements.rs
index e3fbde1..d9eb45b 100644
--- a/nearby/presence/np_adv/src/legacy/random_data_elements.rs
+++ b/nearby/presence/np_adv/src/legacy/random_data_elements.rs
@@ -16,26 +16,26 @@
 
 extern crate std;
 
-use crate::legacy::data_elements::actions::tests::{
-    set_ciphertexttext_action, set_plaintext_action,
-};
+use crate::legacy::data_elements::actions::tests::set_action;
+use crate::legacy::data_elements::device_info::DeviceInfoDataElement;
+use crate::legacy::data_elements::psm::PsmDataElement;
 use crate::{
     legacy::{
         data_elements::{actions::*, de_type::DataElementType, tx_power::TxPowerDataElement, *},
         deserialize::DeserializedDataElement,
-        Ciphertext, PacketFlavor, PacketFlavorEnum, Plaintext,
+        Ciphertext, PacketFlavor, Plaintext,
     },
-    shared_data::TxPower,
+    shared_data::{DeviceInfo, DeviceType, TxPower},
 };
+use alloc::vec;
+use rand::prelude::IteratorRandom;
 use rand_ext::rand::{distributions, prelude::SliceRandom as _};
 use std::prelude::rust_2021::*;
 use strum::IntoEnumIterator;
 
 impl distributions::Distribution<ActionsDataElement<Plaintext>> for distributions::Standard {
     fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> ActionsDataElement<Plaintext> {
-        let mut available_actions = ActionType::iter()
-            .filter(|at| at.supports_flavor(PacketFlavorEnum::Plaintext))
-            .collect::<Vec<_>>();
+        let mut available_actions = actions::tests::supported_action_types();
         available_actions.shuffle(rng);
 
         // choose some of the available actions.
@@ -48,7 +48,7 @@
             // generating boolean actions with `true` since we already did our random selection
             // of which actions to use above
 
-            set_plaintext_action(*a, true, &mut bits);
+            set_action(*a, &mut bits);
         }
 
         ActionsDataElement::from(bits)
@@ -57,9 +57,7 @@
 
 impl distributions::Distribution<ActionsDataElement<Ciphertext>> for distributions::Standard {
     fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> ActionsDataElement<Ciphertext> {
-        let mut available_actions = ActionType::iter()
-            .filter(|at| at.supports_flavor(PacketFlavorEnum::Ciphertext))
-            .collect::<Vec<_>>();
+        let mut available_actions = actions::tests::supported_action_types();
         available_actions.shuffle(rng);
 
         // choose some of the available actions
@@ -71,7 +69,7 @@
         for a in selected_actions {
             // generating boolean actions with `true` since we already did our random selection
             // of which actions to use above
-            set_ciphertexttext_action(*a, true, &mut bits);
+            set_action(*a, &mut bits);
         }
 
         ActionsDataElement::from(bits)
@@ -92,6 +90,25 @@
     }
 }
 
+impl distributions::Distribution<PsmDataElement> for distributions::Standard {
+    fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> PsmDataElement {
+        let psm: u16 = self.sample(rng);
+        PsmDataElement { value: psm }
+    }
+}
+
+impl distributions::Distribution<DeviceInfoDataElement> for distributions::Standard {
+    fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> DeviceInfoDataElement {
+        let device_type = DeviceType::iter().choose(rng).unwrap();
+        let name_len = rng.gen_range(5..=9);
+        let mut device_name = vec![0; name_len];
+        rng.fill(&mut device_name[..]);
+        let device_info =
+            DeviceInfo::try_from((device_type, false, device_name.as_slice())).unwrap();
+        DeviceInfoDataElement { device_info }
+    }
+}
+
 /// Generate a random instance of the requested DE and return it wrapped in [DeserializedDataElement].
 pub(crate) fn rand_de<F, D, E, R>(to_enum: E, rng: &mut R) -> DeserializedDataElement<F>
 where
@@ -117,6 +134,8 @@
     match de_type {
         DataElementType::TxPower => rand_de(DeserializedDataElement::TxPower, rng),
         DataElementType::Actions => rand_de(DeserializedDataElement::Actions, rng),
+        DataElementType::Psm => rand_de(DeserializedDataElement::Psm, rng),
+        DataElementType::DeviceInfo => rand_de(DeserializedDataElement::DeviceInfo, rng),
     }
 }
 
@@ -132,5 +151,7 @@
     match de_type {
         DataElementType::TxPower => rand_de(DeserializedDataElement::TxPower, rng),
         DataElementType::Actions => rand_de(DeserializedDataElement::Actions, rng),
+        DataElementType::Psm => rand_de(DeserializedDataElement::Psm, rng),
+        DataElementType::DeviceInfo => rand_de(DeserializedDataElement::DeviceInfo, rng),
     }
 }
diff --git a/nearby/presence/np_adv/src/legacy/serialize/tests/happy_path.rs b/nearby/presence/np_adv/src/legacy/serialize/tests/happy_path.rs
index 05fc20a..8988678 100644
--- a/nearby/presence/np_adv/src/legacy/serialize/tests/happy_path.rs
+++ b/nearby/presence/np_adv/src/legacy/serialize/tests/happy_path.rs
@@ -16,7 +16,8 @@
     use alloc::vec;
 
     use crate::header::VERSION_HEADER_V0_UNENCRYPTED;
-    use crate::legacy::data_elements::actions::{ActionBits, ActionsDataElement, NearbyShare};
+    use crate::legacy::data_elements::actions::tests::NEARBY_SHARE;
+    use crate::legacy::data_elements::actions::{ActionBits, ActionsDataElement};
     use crate::legacy::data_elements::tx_power::TxPowerDataElement;
     use crate::legacy::serialize::tests::helpers::{LongDataElement, ShortDataElement};
     use crate::legacy::serialize::{AdvBuilder, UnencryptedEncoder};
@@ -80,7 +81,7 @@
         builder.add_data_element(TxPowerDataElement::from(TxPower::try_from(3).unwrap())).unwrap();
 
         let mut action = ActionBits::default();
-        action.set_action(NearbyShare::from(true));
+        action.set_action(NEARBY_SHARE);
         builder.add_data_element(ActionsDataElement::from(action)).unwrap();
 
         let packet = builder.into_advertisement().unwrap();
@@ -106,8 +107,8 @@
 
     use crate::credential::v0::V0BroadcastCredential;
     use crate::header::VERSION_HEADER_V0_LDT;
-    use crate::legacy::data_elements::actions::tests::LastBit;
-    use crate::legacy::data_elements::actions::{ActionBits, ActionsDataElement, PhoneHub};
+    use crate::legacy::data_elements::actions::tests::{LastBit, PHONE_HUB};
+    use crate::legacy::data_elements::actions::{ActionBits, ActionsDataElement};
     use crate::legacy::data_elements::tx_power::TxPowerDataElement;
     use crate::legacy::serialize::tests::helpers::ShortDataElement;
     use crate::legacy::serialize::{AdvBuilder, LdtEncoder, SerializedAdv};
@@ -162,7 +163,7 @@
                 .add_data_element(TxPowerDataElement::from(TxPower::try_from(3).unwrap()))
                 .unwrap();
             let mut action = ActionBits::default();
-            action.set_action(PhoneHub::from(true));
+            action.set_action(PHONE_HUB);
             builder.add_data_element(ActionsDataElement::from(action)).unwrap();
         });
     }
diff --git a/nearby/presence/np_adv/src/legacy/serialize/tests/mod.rs b/nearby/presence/np_adv/src/legacy/serialize/tests/mod.rs
index caaa7d8..ac481ea 100644
--- a/nearby/presence/np_adv/src/legacy/serialize/tests/mod.rs
+++ b/nearby/presence/np_adv/src/legacy/serialize/tests/mod.rs
@@ -40,6 +40,14 @@
             PacketFlavorEnum::Plaintext => true,
             PacketFlavorEnum::Ciphertext => true,
         },
+        DataElementType::Psm => match flavor {
+            PacketFlavorEnum::Plaintext => true,
+            PacketFlavorEnum::Ciphertext => true,
+        },
+        DataElementType::DeviceInfo => match flavor {
+            PacketFlavorEnum::Plaintext => true,
+            PacketFlavorEnum::Ciphertext => true,
+        },
     }
 }
 
@@ -55,7 +63,7 @@
 
     #[test]
     fn unencrypted_encoder() {
-        let _ = format!("{:?}", UnencryptedEncoder);
+        let _ = format!("{UnencryptedEncoder:?}");
     }
 
     #[test]
@@ -72,7 +80,7 @@
         let ldt_encoder = LdtEncoder::<CryptoProviderImpl>::new(salt, &broadcast_cred);
 
         // doesn't leak crypto material
-        assert_eq!("LdtEncoder { salt: V0Salt { bytes: [1, 2] } }", format!("{:?}", ldt_encoder));
+        assert_eq!("LdtEncoder { salt: V0Salt { bytes: [1, 2] } }", format!("{ldt_encoder:?}"));
     }
 
     #[test]
diff --git a/nearby/presence/np_adv/src/shared_data.rs b/nearby/presence/np_adv/src/shared_data.rs
index c7d241b..40d5c72 100644
--- a/nearby/presence/np_adv/src/shared_data.rs
+++ b/nearby/presence/np_adv/src/shared_data.rs
@@ -14,6 +14,10 @@
 
 //! Data types shared between V0 and V1 advertisements
 
+use sink::Sink;
+use strum_macros::FromRepr;
+use tinyvec::ArrayVec;
+
 /// Power in dBm, calibrated as per
 /// [Eddystone](https://github.com/google/eddystone/tree/master/eddystone-uid#tx-power)
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
@@ -76,6 +80,101 @@
 #[derive(Debug, PartialEq, Eq)]
 pub struct ContextSyncSeqNumOutOfRange;
 
+/// A description of what kind of device is broadcasting.
+#[repr(u8)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, FromRepr, strum_macros::EnumIter)]
+pub enum DeviceType {
+    /// The type of device doing the broadcasting is completely unknown.
+    Unknown = 0,
+    /// The broadcasting device is a mobile phone.
+    Phone = 1,
+    /// The broadcasting device is a tablet.
+    Tablet = 2,
+    /// The broadcasting device is a (non-TV) display.
+    Display = 3,
+    /// The broadcasting device is a (non-CrOS) laptop.
+    Laptop = 4,
+    /// The broadcasting device is a TV.
+    TV = 5,
+    /// The broadcasting device is a watch.
+    Watch = 6,
+    /// The broadcasting device is a Chromebook.
+    ChromeOS = 7,
+    /// The broadcasting device is some kind of foldable.
+    Foldable = 8,
+    /// The broadcasting device is a car.
+    Automotive = 9,
+    /// The broadscasting device is a speaker.
+    Speaker = 10,
+}
+
+impl TryFrom<u8> for DeviceType {
+    type Error = ();
+
+    // We implement try_from to maintain the standard Rust idiom for fallible conversions.
+    // This is the expected API for any crate consuming DeviceType directly.
+    fn try_from(value: u8) -> Result<Self, Self::Error> {
+        DeviceType::from_repr(value).ok_or(())
+    }
+}
+
+/// The minimum length of a device name in a `DeviceInfo` data element.
+pub const MIN_DEVICE_NAME_LEN: usize = 5;
+/// The maximum length of a device name in a `DeviceInfo` data element.
+pub const MAX_DEVICE_NAME_LEN: usize = 9;
+
+/// Device information including a device type and a device name.
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct DeviceInfo {
+    device_type: DeviceType,
+    name_truncated: bool,
+    device_name: ArrayVec<[u8; MAX_DEVICE_NAME_LEN]>,
+}
+
+impl DeviceInfo {
+    /// The device type.
+    pub fn device_type(&self) -> DeviceType {
+        self.device_type
+    }
+
+    /// Whether or not the device name was truncated.
+    pub fn name_truncated(&self) -> bool {
+        self.name_truncated
+    }
+
+    /// The device name.
+    pub fn device_name(&self) -> &[u8] {
+        &self.device_name
+    }
+}
+
+/// Errors that can occur when creating a `DeviceInfo`.
+#[derive(Debug, PartialEq, Eq)]
+pub enum DeviceInfoMalformed {
+    /// The device name is too short.
+    NameTooShort,
+    /// The device name is too long.
+    NameTooLong,
+}
+
+impl<'a> TryFrom<(DeviceType, bool, &'a [u8])> for DeviceInfo {
+    type Error = DeviceInfoMalformed;
+
+    fn try_from(
+        (device_type, name_truncated, device_name): (DeviceType, bool, &'a [u8]),
+    ) -> Result<Self, Self::Error> {
+        if device_name.len() < MIN_DEVICE_NAME_LEN {
+            Err(DeviceInfoMalformed::NameTooShort)
+        } else if device_name.len() > MAX_DEVICE_NAME_LEN {
+            Err(DeviceInfoMalformed::NameTooLong)
+        } else {
+            let mut array_vec = ArrayVec::new();
+            array_vec.try_extend_from_slice(device_name).ok_or(DeviceInfoMalformed::NameTooLong)?;
+            Ok(Self { device_type, name_truncated, device_name: array_vec })
+        }
+    }
+}
+
 #[allow(clippy::unwrap_used)]
 #[cfg(test)]
 mod tests {
@@ -102,4 +201,29 @@
     fn context_sync_seq_num_ok() {
         assert_eq!(0x0F, ContextSyncSeqNum::try_from(0x0F).unwrap().num);
     }
+
+    #[test]
+    fn device_info_name_too_short() {
+        assert_eq!(
+            DeviceInfoMalformed::NameTooShort,
+            DeviceInfo::try_from((DeviceType::Phone, false, "abcd".as_bytes())).unwrap_err()
+        );
+    }
+
+    #[test]
+    fn device_info_name_too_long() {
+        assert_eq!(
+            DeviceInfoMalformed::NameTooLong,
+            DeviceInfo::try_from((DeviceType::Phone, false, "0123456789".as_bytes())).unwrap_err()
+        );
+    }
+
+    #[test]
+    fn device_info_ok() {
+        let device_info =
+            DeviceInfo::try_from((DeviceType::Phone, true, "abcde".as_bytes())).unwrap();
+        assert_eq!(device_info.device_type(), DeviceType::Phone);
+        assert!(device_info.name_truncated());
+        assert_eq!(device_info.device_name(), "abcde".as_bytes());
+    }
 }
diff --git a/nearby/presence/np_adv_dynamic/src/extended.rs b/nearby/presence/np_adv_dynamic/src/extended.rs
index 59f6a42..c71d0a2 100644
--- a/nearby/presence/np_adv_dynamic/src/extended.rs
+++ b/nearby/presence/np_adv_dynamic/src/extended.rs
@@ -43,7 +43,7 @@
     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
         match self {
             BoxedAddSectionError::Underlying(u) => {
-                write!(f, "{0}", u)
+                write!(f, "{u}")
             }
         }
     }
diff --git a/nearby/presence/np_adv_dynamic/src/legacy.rs b/nearby/presence/np_adv_dynamic/src/legacy.rs
index 68eb1bc..c35318b 100644
--- a/nearby/presence/np_adv_dynamic/src/legacy.rs
+++ b/nearby/presence/np_adv_dynamic/src/legacy.rs
@@ -15,7 +15,9 @@
 use crypto_provider::CryptoProvider;
 use np_adv::{
     legacy::{
-        data_elements::{actions::*, tx_power::TxPowerDataElement, *},
+        data_elements::{
+            actions::*, device_info::DeviceInfoDataElement, tx_power::TxPowerDataElement, *,
+        },
         serialize::*,
         *,
     },
@@ -128,7 +130,7 @@
     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
         match self {
             BoxedAddDataElementError::UnderlyingError(u) => {
-                write!(f, "{0:?}", u)
+                write!(f, "{u:?}")
             }
             BoxedAddDataElementError::FlavorMismatchError => {
                 write!(f, "Expected packet flavoring for added DEs does not match the actual packet flavor of the DE")
@@ -232,6 +234,12 @@
     }
 }
 
+impl From<DeviceInfo> for ToBoxedSerializeDataElement {
+    fn from(data: DeviceInfo) -> Self {
+        ToBoxedSerializeDataElement::Both(Box::new(DeviceInfoDataElement::from(data)))
+    }
+}
+
 impl From<BoxedActionBits> for ToBoxedSerializeDataElement {
     fn from(action_bits: BoxedActionBits) -> Self {
         match action_bits {
@@ -247,19 +255,21 @@
 
 /// Boxed version of `ToActionElement` which allows abstracting over
 /// what packet flavors are supported by a given action.
-pub enum ToBoxedActionElement {
-    /// Action bit for cross device SDK.
-    CrossDevSdk(bool),
-    /// Action bit for call transfer.
-    CallTransfer(bool),
-    /// Action bit for active unlock.
-    ActiveUnlock(bool),
-    /// Action bit for nearby share.
-    NearbyShare(bool),
-    /// Action bit for instant tethering.
-    InstantTethering(bool),
-    /// Action bit for PhoneHub.
-    PhoneHub(bool),
+pub struct ToBoxedActionElement {
+    /// u8 representation of the action element
+    pub action: u8,
+    /// bool value of whether element is active
+    pub value: bool,
+}
+
+impl ActionElement for ToBoxedActionElement {
+    fn high_bit_index(&self) -> u32 {
+        self.action as u32
+    }
+
+    fn bits(&self) -> u8 {
+        self.value as u8
+    }
 }
 
 /// [`ActionBits`] with runtime-determined packet flavoring
@@ -308,7 +318,7 @@
         }
     }
 
-    fn set<F: PacketFlavor, E: ActionElementFlavor<F>>(
+    fn set<F: PacketFlavor, E: ActionElement>(
         action_bits: &mut ActionBits<F>,
         to_element: E,
     ) -> Result<(), BoxedSetActionFlavorError> {
@@ -324,36 +334,8 @@
         to_element: ToBoxedActionElement,
     ) -> Result<(), BoxedSetActionFlavorError> {
         match self {
-            BoxedActionBits::Plaintext(action_bits) => match to_element {
-                ToBoxedActionElement::CrossDevSdk(b) => {
-                    Self::set(action_bits, CrossDevSdk::from(b))
-                }
-                ToBoxedActionElement::NearbyShare(b) => {
-                    Self::set(action_bits, NearbyShare::from(b))
-                }
-                ToBoxedActionElement::CallTransfer(_)
-                | ToBoxedActionElement::ActiveUnlock(_)
-                | ToBoxedActionElement::InstantTethering(_)
-                | ToBoxedActionElement::PhoneHub(_) => Err(BoxedSetActionFlavorError),
-            },
-            BoxedActionBits::Ciphertext(action_bits) => match to_element {
-                ToBoxedActionElement::CrossDevSdk(b) => {
-                    Self::set(action_bits, CrossDevSdk::from(b))
-                }
-                ToBoxedActionElement::CallTransfer(b) => {
-                    Self::set(action_bits, CallTransfer::from(b))
-                }
-                ToBoxedActionElement::ActiveUnlock(b) => {
-                    Self::set(action_bits, ActiveUnlock::from(b))
-                }
-                ToBoxedActionElement::NearbyShare(b) => {
-                    Self::set(action_bits, NearbyShare::from(b))
-                }
-                ToBoxedActionElement::InstantTethering(b) => {
-                    Self::set(action_bits, InstantTethering::from(b))
-                }
-                ToBoxedActionElement::PhoneHub(b) => Self::set(action_bits, PhoneHub::from(b)),
-            },
+            BoxedActionBits::Plaintext(action_bits) => Self::set(action_bits, to_element),
+            BoxedActionBits::Ciphertext(action_bits) => Self::set(action_bits, to_element),
         }
     }
 }
diff --git a/nearby/presence/np_c_ffi/include/c/np_c_ffi.h b/nearby/presence/np_c_ffi/include/c/np_c_ffi.h
index e932671..82d37d3 100644
--- a/nearby/presence/np_c_ffi/include/c/np_c_ffi.h
+++ b/nearby/presence/np_c_ffi/include/c/np_c_ffi.h
@@ -35,19 +35,6 @@
 #include <stdlib.h>
 
 /**
- * The possible boolean action types which can be present in an Actions data element
- */
-enum np_ffi_ActionType {
-  NP_FFI_ACTION_TYPE_CROSS_DEV_SDK = 1,
-  NP_FFI_ACTION_TYPE_CALL_TRANSFER = 4,
-  NP_FFI_ACTION_TYPE_ACTIVE_UNLOCK = 8,
-  NP_FFI_ACTION_TYPE_NEARBY_SHARE = 9,
-  NP_FFI_ACTION_TYPE_INSTANT_TETHERING = 10,
-  NP_FFI_ACTION_TYPE_PHONE_HUB = 11,
-};
-typedef uint8_t np_ffi_ActionType;
-
-/**
  * Result type for trying to add a V0 credential to a credential-slab.
  */
 enum np_ffi_AddV0CredentialToSlabResult {
@@ -178,6 +165,39 @@
 typedef uint8_t np_ffi_AdvertisementBuilderKind;
 
 /**
+ * Discriminant for `BuildActionTypeResult`.
+ */
+enum np_ffi_BuildActionTypeResultKind {
+  /**
+   * The action type was outside the
+   * allowed 0 to 31 range.
+   */
+  NP_FFI_BUILD_ACTION_TYPE_RESULT_KIND_OUT_OF_RANGE = 0,
+  /**
+   * The transmission power was in range,
+   * and so a `TxPower` struct was constructed.
+   */
+  NP_FFI_BUILD_ACTION_TYPE_RESULT_KIND_SUCCESS = 1,
+};
+typedef uint8_t np_ffi_BuildActionTypeResultKind;
+
+/**
+ * Discriminant for `BuildPsmResult`.
+ */
+enum np_ffi_BuildPsmResultKind {
+  /**
+   * The PSM should be within two bytes.
+   */
+  NP_FFI_BUILD_PSM_RESULT_KIND_OUT_OF_RANGE = 0,
+  /**
+   * The PSM was in range,
+   * and so a `Psm` struct was constructed.
+   */
+  NP_FFI_BUILD_PSM_RESULT_KIND_SUCCESS = 1,
+};
+typedef uint8_t np_ffi_BuildPsmResultKind;
+
+/**
  * Discriminant for `BuildTxPowerResult`.
  */
 enum np_ffi_BuildTxPowerResultKind {
@@ -592,6 +612,18 @@
    * `V0DataElement#into_actions`.
    */
   NP_FFI_V0_DATA_ELEMENT_KIND_ACTIONS = 2,
+  /**
+   * The PSM data-element.
+   * The associated payload may be obtained via
+   * `V0DataElement#into_psm`.
+   */
+  NP_FFI_V0_DATA_ELEMENT_KIND_PSM = 3,
+  /**
+   * The DeviceInfo data-element.
+   * The associated payload may be obtained via
+   * 'V0DataElement#into_device_info`.
+   */
+  NP_FFI_V0_DATA_ELEMENT_KIND_DEVICE_INFO = 4,
 };
 typedef uint8_t np_ffi_V0DataElementKind;
 
@@ -879,7 +911,13 @@
  * permitted to be between 0 and 255.
  */
 typedef struct {
+  /**
+   * The number of bytes in the buffer.
+   */
   uint8_t len;
+  /**
+   * The bytes in the buffer.
+   */
   uint8_t bytes[255];
 } np_ffi_ByteBuffer_255;
 
@@ -933,11 +971,89 @@
 } np_ffi_V0Actions;
 
 /**
+ * Representation of a PSM for L2CAP,
+ */
+typedef struct {
+  uint16_t value;
+} np_ffi_Psm;
+
+/**
+ * A byte-string with a maximum size of N,
+ * where only the first `len` bytes are considered
+ * to contain the actual payload. N is only
+ * permitted to be between 0 and 255.
+ */
+typedef struct {
+  /**
+   * The number of bytes in the buffer.
+   */
+  uint8_t len;
+  /**
+   * The bytes in the buffer.
+   */
+  uint8_t bytes[9];
+} np_ffi_ByteBuffer_9;
+
+/**
+ * A FFI safe wrapper of an optional byte buffer.
+ */
+typedef enum {
+  /**
+   * The optional byte buffer isn't specified.
+   */
+  NP_FFI_OPTIONAL_BYTE_BUFFER_9_NOT_SPECIFIED_9,
+  /**
+   * The optional byte buffer is present.
+   */
+  NP_FFI_OPTIONAL_BYTE_BUFFER_9_PRESENT_9,
+} np_ffi_OptionalByteBuffer_9_Tag;
+
+typedef struct {
+  np_ffi_OptionalByteBuffer_9_Tag tag;
+  union {
+    struct {
+      np_ffi_ByteBuffer_9 present;
+    };
+  };
+} np_ffi_OptionalByteBuffer_9;
+
+/**
+ * Represents the contents of a Device Info DE containing the type and name of a device, as well as flag
+ * indicating whether the device name is truncated.
+ *
+ * This representation is stable, and so you may directly
+ * reference this struct's fields if you wish.
+ */
+typedef struct {
+  /**
+   * The numerical value corresponding to a given
+   * form factor, or "0" for the case where the
+   * broadcasting device doesn't know what kind
+   * of device type it is.
+   *
+   * Consult the Nearby spec for values
+   * corresponding to particular form factors.
+   */
+  uint8_t device_type;
+  /**
+   * Boolean flag indicating whether the device name is truncated.
+   */
+  bool is_name_truncated;
+  /**
+   * An optional byte array representing the name of the broadcasting
+   * device. The maximum size of this device name array is 9 bytes.
+   */
+  np_ffi_OptionalByteBuffer_9 device_name;
+} np_ffi_DeviceInfo;
+
+/**
  * Representation of a V0 data element.
  */
 typedef enum {
   NP_FFI_V0_DATA_ELEMENT_TX_POWER,
   NP_FFI_V0_DATA_ELEMENT_ACTIONS,
+  NP_FFI_V0_DATA_ELEMENT_PSM,
+  NP_FFI_V0_DATA_ELEMENT_DEVICE_INFO,
 } np_ffi_V0DataElement_Tag;
 
 typedef struct {
@@ -949,6 +1065,12 @@
     struct {
       np_ffi_V0Actions actions;
     };
+    struct {
+      np_ffi_Psm psm;
+    };
+    struct {
+      np_ffi_DeviceInfo device_info;
+    };
   };
 } np_ffi_V0DataElement;
 
@@ -1060,7 +1182,13 @@
  * permitted to be between 0 and 255.
  */
 typedef struct {
+  /**
+   * The number of bytes in the buffer.
+   */
   uint8_t len;
+  /**
+   * The bytes in the buffer.
+   */
   uint8_t bytes[127];
 } np_ffi_ByteBuffer_127;
 
@@ -1184,7 +1312,13 @@
  * permitted to be between 0 and 255.
  */
 typedef struct {
+  /**
+   * The number of bytes in the buffer.
+   */
   uint8_t len;
+  /**
+   * The bytes in the buffer.
+   */
   uint8_t bytes[24];
 } np_ffi_ByteBuffer_24;
 
@@ -1285,7 +1419,13 @@
  * permitted to be between 0 and 255.
  */
 typedef struct {
+  /**
+   * The number of bytes in the buffer.
+   */
   uint8_t len;
+  /**
+   * The bytes in the buffer.
+   */
   uint8_t bytes[250];
 } np_ffi_ByteBuffer_250;
 
@@ -1367,6 +1507,49 @@
 } np_ffi_SetV0ActionResult;
 
 /**
+ * Action data element
+ */
+typedef struct {
+  uint8_t _0;
+} np_ffi_ActionType;
+
+/**
+ * Result type for attempting to construct a
+ * PSM from two bytes.
+ */
+typedef enum {
+  NP_FFI_BUILD_PSM_RESULT_OUT_OF_RANGE,
+  NP_FFI_BUILD_PSM_RESULT_SUCCESS,
+} np_ffi_BuildPsmResult_Tag;
+
+typedef struct {
+  np_ffi_BuildPsmResult_Tag tag;
+  union {
+    struct {
+      np_ffi_Psm success;
+    };
+  };
+} np_ffi_BuildPsmResult;
+
+/**
+ * Result type for attempting to construct a
+ * ActionType from an usigned byte.
+ */
+typedef enum {
+  NP_FFI_BUILD_ACTION_TYPE_RESULT_OUT_OF_RANGE,
+  NP_FFI_BUILD_ACTION_TYPE_RESULT_SUCCESS,
+} np_ffi_BuildActionTypeResult_Tag;
+
+typedef struct {
+  np_ffi_BuildActionTypeResult_Tag tag;
+  union {
+    struct {
+      np_ffi_ActionType success;
+    };
+  };
+} np_ffi_BuildActionTypeResult;
+
+/**
  * Overrides the global panic handler to be used when NP C FFI calls panic.
  * This method will only have an effect on the global panic-handler
  * the first time it's called, and this method will return `true`
@@ -1971,6 +2154,72 @@
 uint32_t np_ffi_V0Actions_as_u32(np_ffi_V0Actions actions);
 
 /**
+ * Casts a `V0DataElement` to the `Psm` variant, panicking in the
+ * case where the passed value is of a different enum variant.
+ */
+np_ffi_Psm np_ffi_V0DataElement_into_PSM(np_ffi_V0DataElement de);
+
+/**
+ * Upcasts a Psm DE to a generic V0 data-element.
+ */
+np_ffi_V0DataElement np_ffi_Psm_into_V0DataElement(np_ffi_Psm psm);
+
+/**
+ * Casts a `V0DataElement` to the `DeviceInfo` variant, panicking in the
+ * case where the passed value is of a different enum variant.
+ */
+np_ffi_DeviceInfo np_ffi_V0DataElement_into_DeviceInfo(np_ffi_V0DataElement de);
+
+/**
+ * Upcasts a DeviceInfo DE to a generic V0 data-element.
+ */
+np_ffi_V0DataElement np_ffi_DeviceInfo_into_V0DataElement(np_ffi_DeviceInfo device_info);
+
+/**
+ * Gets the value of the given PSM as a unsigned u16.
+ */
+uint16_t np_ffi_Psm_as_unsigned_u16(np_ffi_Psm psm);
+
+/**
+ * Attempts to construct a new PSM from
+ * the given u16 value.
+ */
+np_ffi_BuildPsmResult np_ffi_Psm_build_from_u16(uint16_t psm);
+
+/**
+ * Gets the tag of a `BuildTxPowerResult` tagged-union.
+ */
+np_ffi_BuildPsmResultKind np_ffi_BuildPsmResult_kind(np_ffi_BuildPsmResult result);
+
+/**
+ * Casts a `BuildPsmResult` to the `Success` variant, panicking in the
+ * case where the passed value is of a different enum variant.
+ */
+np_ffi_Psm np_ffi_BuildPsmResult_into_SUCCESS(np_ffi_BuildPsmResult result);
+
+/**
+ * Gets the tag of a `BuildActionTypeResult` tagged-union.
+ */
+np_ffi_BuildActionTypeResultKind np_ffi_BuildActionTypeResult_kind(np_ffi_BuildActionTypeResult result);
+
+/**
+ * Casts a `BuildActionTypeResult` to the `Success` variant, panicking in the
+ * case where the passed value is of a different enum variant.
+ */
+np_ffi_ActionType np_ffi_BuildActionTypeResult_into_SUCCESS(np_ffi_BuildActionTypeResult result);
+
+/**
+ * Attempts to construct a new ActionType from
+ * the given signed-byte value.
+ */
+np_ffi_BuildActionTypeResult np_ffi_ActionType_build_from_unsigned_byte(uint8_t action_type);
+
+/**
+ * Gets the value of the given ActionType as an unsigned byte.
+ */
+uint8_t np_ffi_ActionType_as_unsigned_byte(np_ffi_ActionType action_type);
+
+/**
  * Extracts the numerical value of the given V1 DE type code as
  * an unsigned 32-bit integer.
  */
diff --git a/nearby/presence/np_c_ffi/include/cpp/np_cpp_ffi_functions.h b/nearby/presence/np_c_ffi/include/cpp/np_cpp_ffi_functions.h
index 649837e..d23e620 100644
--- a/nearby/presence/np_c_ffi/include/cpp/np_cpp_ffi_functions.h
+++ b/nearby/presence/np_c_ffi/include/cpp/np_cpp_ffi_functions.h
@@ -462,6 +462,48 @@
 /// integer, where the bit-positions correspond to individual actions.
 uint32_t np_ffi_V0Actions_as_u32(V0Actions actions);
 
+/// Casts a `V0DataElement` to the `Psm` variant, panicking in the
+/// case where the passed value is of a different enum variant.
+Psm np_ffi_V0DataElement_into_PSM(V0DataElement de);
+
+/// Upcasts a Psm DE to a generic V0 data-element.
+V0DataElement np_ffi_Psm_into_V0DataElement(Psm psm);
+
+/// Casts a `V0DataElement` to the `DeviceInfo` variant, panicking in the
+/// case where the passed value is of a different enum variant.
+DeviceInfo np_ffi_V0DataElement_into_DeviceInfo(V0DataElement de);
+
+/// Upcasts a DeviceInfo DE to a generic V0 data-element.
+V0DataElement np_ffi_DeviceInfo_into_V0DataElement(DeviceInfo device_info);
+
+/// Gets the value of the given PSM as a unsigned u16.
+uint16_t np_ffi_Psm_as_unsigned_u16(Psm psm);
+
+/// Attempts to construct a new PSM from
+/// the given u16 value.
+BuildPsmResult np_ffi_Psm_build_from_u16(uint16_t psm);
+
+/// Gets the tag of a `BuildTxPowerResult` tagged-union.
+BuildPsmResultKind np_ffi_BuildPsmResult_kind(BuildPsmResult result);
+
+/// Casts a `BuildPsmResult` to the `Success` variant, panicking in the
+/// case where the passed value is of a different enum variant.
+Psm np_ffi_BuildPsmResult_into_SUCCESS(BuildPsmResult result);
+
+/// Gets the tag of a `BuildActionTypeResult` tagged-union.
+BuildActionTypeResultKind np_ffi_BuildActionTypeResult_kind(BuildActionTypeResult result);
+
+/// Casts a `BuildActionTypeResult` to the `Success` variant, panicking in the
+/// case where the passed value is of a different enum variant.
+ActionType np_ffi_BuildActionTypeResult_into_SUCCESS(BuildActionTypeResult result);
+
+/// Attempts to construct a new ActionType from
+/// the given signed-byte value.
+BuildActionTypeResult np_ffi_ActionType_build_from_unsigned_byte(uint8_t action_type);
+
+/// Gets the value of the given ActionType as an unsigned byte.
+uint8_t np_ffi_ActionType_as_unsigned_byte(ActionType action_type);
+
 /// Extracts the numerical value of the given V1 DE type code as
 /// an unsigned 32-bit integer.
 uint32_t np_ffi_V1DEType_to_uint32_t(V1DEType de_type);
diff --git a/nearby/presence/np_c_ffi/include/cpp/np_cpp_ffi_types.h b/nearby/presence/np_c_ffi/include/cpp/np_cpp_ffi_types.h
index 5144478..50fdd49 100644
--- a/nearby/presence/np_c_ffi/include/cpp/np_cpp_ffi_types.h
+++ b/nearby/presence/np_c_ffi/include/cpp/np_cpp_ffi_types.h
@@ -38,16 +38,6 @@
 namespace np_ffi {
 namespace internal {
 
-/// The possible boolean action types which can be present in an Actions data element
-enum class ActionType : uint8_t {
-  CrossDevSdk = 1,
-  CallTransfer = 4,
-  ActiveUnlock = 8,
-  NearbyShare = 9,
-  InstantTethering = 10,
-  PhoneHub = 11,
-};
-
 /// Result type for trying to add a V0 credential to a credential-slab.
 enum class AddV0CredentialToSlabResult : uint8_t {
   /// We succeeded in adding the credential to the slab.
@@ -124,6 +114,25 @@
   Encrypted = 1,
 };
 
+/// Discriminant for `BuildActionTypeResult`.
+enum class BuildActionTypeResultKind : uint8_t {
+  /// The action type was outside the
+  /// allowed 0 to 31 range.
+  OutOfRange = 0,
+  /// The transmission power was in range,
+  /// and so a `TxPower` struct was constructed.
+  Success = 1,
+};
+
+/// Discriminant for `BuildPsmResult`.
+enum class BuildPsmResultKind : uint8_t {
+  /// The PSM should be within two bytes.
+  OutOfRange = 0,
+  /// The PSM was in range,
+  /// and so a `Psm` struct was constructed.
+  Success = 1,
+};
+
 /// Discriminant for `BuildTxPowerResult`.
 enum class BuildTxPowerResultKind : uint8_t {
   /// The transmission power was outside the
@@ -384,6 +393,14 @@
   /// The associated payload may be obtained via
   /// `V0DataElement#into_actions`.
   Actions = 2,
+  /// The PSM data-element.
+  /// The associated payload may be obtained via
+  /// `V0DataElement#into_psm`.
+  Psm = 3,
+  /// The DeviceInfo data-element.
+  /// The associated payload may be obtained via
+  /// 'V0DataElement#into_device_info`.
+  DeviceInfo = 4,
 };
 
 /// Discriminant for `V1DE16ByteSaltResult`.
@@ -617,7 +634,9 @@
 /// permitted to be between 0 and 255.
 template<uintptr_t N>
 struct ByteBuffer {
+  /// The number of bytes in the buffer.
   uint8_t len;
+  /// The bytes in the buffer.
   uint8_t bytes[N];
 };
 
@@ -662,11 +681,59 @@
   };
 };
 
+/// Representation of a PSM for L2CAP,
+struct Psm {
+  uint16_t value;
+};
+
+/// A FFI safe wrapper of an optional byte buffer.
+template<uintptr_t N>
+struct OptionalByteBuffer {
+  enum class Tag {
+    /// The optional byte buffer isn't specified.
+    NotSpecified,
+    /// The optional byte buffer is present.
+    Present,
+  };
+
+  struct Present_Body {
+    ByteBuffer<N> _0;
+  };
+
+  Tag tag;
+  union {
+    Present_Body present;
+  };
+};
+
+/// Represents the contents of a Device Info DE containing the type and name of a device, as well as flag
+/// indicating whether the device name is truncated.
+///
+/// This representation is stable, and so you may directly
+/// reference this struct's fields if you wish.
+struct DeviceInfo {
+  /// The numerical value corresponding to a given
+  /// form factor, or "0" for the case where the
+  /// broadcasting device doesn't know what kind
+  /// of device type it is.
+  ///
+  /// Consult the Nearby spec for values
+  /// corresponding to particular form factors.
+  uint8_t device_type;
+  /// Boolean flag indicating whether the device name is truncated.
+  bool is_name_truncated;
+  /// An optional byte array representing the name of the broadcasting
+  /// device. The maximum size of this device name array is 9 bytes.
+  OptionalByteBuffer<9> device_name;
+};
+
 /// Representation of a V0 data element.
 struct V0DataElement {
   enum class Tag {
     TxPower,
     Actions,
+    Psm,
+    DeviceInfo,
   };
 
   struct TxPower_Body {
@@ -677,10 +744,20 @@
     V0Actions _0;
   };
 
+  struct Psm_Body {
+    Psm _0;
+  };
+
+  struct DeviceInfo_Body {
+    DeviceInfo _0;
+  };
+
   Tag tag;
   union {
     TxPower_Body tx_power;
     Actions_Body actions;
+    Psm_Body psm;
+    DeviceInfo_Body device_info;
   };
 };
 
@@ -1013,6 +1090,47 @@
   };
 };
 
+/// Action data element
+struct ActionType {
+  uint8_t _0;
+};
+
+/// Result type for attempting to construct a
+/// PSM from two bytes.
+struct BuildPsmResult {
+  enum class Tag {
+    OutOfRange,
+    Success,
+  };
+
+  struct Success_Body {
+    Psm _0;
+  };
+
+  Tag tag;
+  union {
+    Success_Body success;
+  };
+};
+
+/// Result type for attempting to construct a
+/// ActionType from an usigned byte.
+struct BuildActionTypeResult {
+  enum class Tag {
+    OutOfRange,
+    Success,
+  };
+
+  struct Success_Body {
+    ActionType _0;
+  };
+
+  Tag tag;
+  union {
+    Success_Body success;
+  };
+};
+
 }  // namespace internal
 }  // namespace np_ffi
 
diff --git a/nearby/presence/np_c_ffi/src/lib.rs b/nearby/presence/np_c_ffi/src/lib.rs
index 5111aff..6762527 100644
--- a/nearby/presence/np_c_ffi/src/lib.rs
+++ b/nearby/presence/np_c_ffi/src/lib.rs
@@ -92,9 +92,9 @@
     }
 
     fn system_handler(panic_reason: PanicReason) -> ! {
-        std::eprintln!("NP FFI Panicked: {:?}", panic_reason);
+        std::eprintln!("NP FFI Panicked: {panic_reason:?}");
         let backtrace = std::backtrace::Backtrace::capture();
-        std::eprintln!("Stack trace: {}", backtrace);
+        std::eprintln!("Stack trace: {backtrace}");
         std::process::abort()
     }
 }
diff --git a/nearby/presence/np_c_ffi/src/v0.rs b/nearby/presence/np_c_ffi/src/v0.rs
index 84aab95..56741da 100644
--- a/nearby/presence/np_c_ffi/src/v0.rs
+++ b/nearby/presence/np_c_ffi/src/v0.rs
@@ -139,3 +139,87 @@
 pub extern "C" fn np_ffi_V0Actions_as_u32(actions: V0Actions) -> u32 {
     actions.as_u32()
 }
+
+/// Casts a `V0DataElement` to the `Psm` variant, panicking in the
+/// case where the passed value is of a different enum variant.
+#[no_mangle]
+pub extern "C" fn np_ffi_V0DataElement_into_PSM(de: V0DataElement) -> Psm {
+    unwrap(de.into_psm(), PanicReason::EnumCastFailed)
+}
+
+/// Upcasts a Psm DE to a generic V0 data-element.
+#[no_mangle]
+pub extern "C" fn np_ffi_Psm_into_V0DataElement(psm: Psm) -> V0DataElement {
+    V0DataElement::Psm(psm)
+}
+
+/// Casts a `V0DataElement` to the `DeviceInfo` variant, panicking in the
+/// case where the passed value is of a different enum variant.
+#[no_mangle]
+pub extern "C" fn np_ffi_V0DataElement_into_DeviceInfo(de: V0DataElement) -> DeviceInfo {
+    unwrap(de.into_device_info(), PanicReason::EnumCastFailed)
+}
+
+/// Upcasts a DeviceInfo DE to a generic V0 data-element.
+#[no_mangle]
+pub extern "C" fn np_ffi_DeviceInfo_into_V0DataElement(device_info: DeviceInfo) -> V0DataElement {
+    V0DataElement::DeviceInfo(device_info)
+}
+
+/// Gets the value of the given PSM as a unsigned u16.
+#[no_mangle]
+pub extern "C" fn np_ffi_Psm_as_unsigned_u16(psm: Psm) -> u16 {
+    psm.as_u16()
+}
+
+/// Attempts to construct a new PSM from
+/// the given u16 value.
+#[no_mangle]
+pub extern "C" fn np_ffi_Psm_build_from_u16(psm: u16) -> BuildPsmResult {
+    Psm::build_from_unsigned_bytes(psm)
+}
+
+/// Gets the tag of a `BuildTxPowerResult` tagged-union.
+#[no_mangle]
+pub extern "C" fn np_ffi_BuildPsmResult_kind(result: BuildPsmResult) -> BuildPsmResultKind {
+    result.kind()
+}
+
+/// Casts a `BuildPsmResult` to the `Success` variant, panicking in the
+/// case where the passed value is of a different enum variant.
+#[no_mangle]
+pub extern "C" fn np_ffi_BuildPsmResult_into_SUCCESS(result: BuildPsmResult) -> Psm {
+    unwrap(result.into_success(), PanicReason::EnumCastFailed)
+}
+
+/// Gets the tag of a `BuildActionTypeResult` tagged-union.
+#[no_mangle]
+pub extern "C" fn np_ffi_BuildActionTypeResult_kind(
+    result: BuildActionTypeResult,
+) -> BuildActionTypeResultKind {
+    result.kind()
+}
+
+/// Casts a `BuildActionTypeResult` to the `Success` variant, panicking in the
+/// case where the passed value is of a different enum variant.
+#[no_mangle]
+pub extern "C" fn np_ffi_BuildActionTypeResult_into_SUCCESS(
+    result: BuildActionTypeResult,
+) -> ActionType {
+    unwrap(result.into_success(), PanicReason::EnumCastFailed)
+}
+
+/// Attempts to construct a new ActionType from
+/// the given signed-byte value.
+#[no_mangle]
+pub extern "C" fn np_ffi_ActionType_build_from_unsigned_byte(
+    action_type: u8,
+) -> BuildActionTypeResult {
+    ActionType::build_from_unsigned_byte(action_type)
+}
+
+/// Gets the value of the given ActionType as an unsigned byte.
+#[no_mangle]
+pub extern "C" fn np_ffi_ActionType_as_unsigned_byte(action_type: ActionType) -> u8 {
+    action_type.as_u8()
+}
diff --git a/nearby/presence/np_cpp_ffi/fuzz/deserialization_fuzzer.cc b/nearby/presence/np_cpp_ffi/fuzz/deserialization_fuzzer.cc
index 24614e9..65f4125 100644
--- a/nearby/presence/np_cpp_ffi/fuzz/deserialization_fuzzer.cc
+++ b/nearby/presence/np_cpp_ffi/fuzz/deserialization_fuzzer.cc
@@ -284,6 +284,13 @@
       [[maybe_unused]] auto actions = de.AsActions();
       break;
     }
+    case nearby_protocol::V0DataElementKind::Psm: {
+      [[maybe_unused]] auto psm = de.AsPsm();
+      break;
+    }
+    case nearby_protocol::V0DataElementKind::DeviceInfo: {
+      break;
+    }
   }
 }
 
diff --git a/nearby/presence/np_cpp_ffi/include/nearby_protocol.h b/nearby/presence/np_cpp_ffi/include/nearby_protocol.h
index 08b7803..14d9170 100644
--- a/nearby/presence/np_cpp_ffi/include/nearby_protocol.h
+++ b/nearby/presence/np_cpp_ffi/include/nearby_protocol.h
@@ -61,7 +61,6 @@
 namespace nearby_protocol {
 
 // Re-exporting cbindgen generated types which are used in the public API
-using np_ffi::internal::ActionType;
 using np_ffi::internal::AddV0CredentialToSlabResult;
 using np_ffi::internal::AddV0DEResult;
 using np_ffi::internal::AddV1CredentialToSlabResult;
@@ -579,6 +578,71 @@
   np_ffi::internal::TxPower tx_power_;
 };
 
+// A Psm for L2CAP.
+class Psm {
+  public:
+    Psm() = delete;
+
+  // Gets the value of this PSM as a unsigned int of two bytes.
+  [[nodiscard]] uint16_t GetAsU16() const;
+
+  // Attempts to construct a tx power with the given value contained
+  // in a signed byte. If the number is not within the representable
+  // range, this method will return an invalid argument error.
+  [[nodiscard]] static absl::StatusOr<Psm> TryBuildFromU16(uint16_t value);
+
+ private:
+  friend class V0DataElement;
+  explicit Psm(np_ffi::internal::Psm psm) : psm_(psm) {}
+  np_ffi::internal::Psm psm_;
+};
+
+// A DeviceInfo
+class DeviceInfo {
+ public:
+  DeviceInfo() = delete;
+
+  // Gets the device type.
+  [[nodiscard]] uint8_t GetDeviceType() const;
+
+  // Gets whether the name is truncated.
+  [[nodiscard]] bool IsNameTruncated() const;
+
+  // Gets the device name.
+  [[nodiscard]] np_ffi::internal::OptionalByteBuffer<9> GetDeviceName() const;
+
+  // Attempts to construct a device info.
+  [[nodiscard]] static DeviceInfo Build(
+      uint8_t device_type, bool is_name_truncated,
+      np_ffi::internal::OptionalByteBuffer<9> device_name);
+
+ private:
+  friend class V0DataElement;
+  explicit DeviceInfo(np_ffi::internal::DeviceInfo device_info)
+      : device_info_(device_info) {}
+  np_ffi::internal::DeviceInfo device_info_;
+};
+
+// An ActionType
+class ActionType {
+ public:
+  ActionType() = delete;
+
+  // Gets the value of this ActionType as a unsigned int 1 byte.
+  [[nodiscard]] uint8_t GetAsU8() const;
+
+  // Attempts to construct a action type with the given value contained
+  // in a unsigned byte. If the number is not within the representable
+  // range, this method will return an invalid argument error.
+  [[nodiscard]] static absl::StatusOr<ActionType> TryBuildFromU8(uint8_t value);
+
+ private:
+  friend class V0Actions;
+  explicit ActionType(np_ffi::internal::ActionType action_type)
+      : action_type_(action_type) {}
+  np_ffi::internal::ActionType action_type_;
+};
+
 // A single V0 data element
 class V0DataElement {
  public:
@@ -587,12 +651,23 @@
   // Casts the V0DataElement into the TxPower variant, panicking in the case
   // where the data element is of a different enum variant
   [[nodiscard]] TxPower AsTxPower() const;
+  // Casts the V0DataElement into the Psm variant, panicking in the case
+  // where the data element is of a different enum variant
+  [[nodiscard]] Psm AsPsm() const;
+  // Casts the V0DataElement into the DeviceInfo variant, panicking in the case
+  // where the data element is of a different enum variant
+  [[nodiscard]] DeviceInfo AsDeviceInfo() const;
+
   // Casts the V0DataElement into the Actions variant, panicking in the case
   // where the data element is of a different enum variant
   [[nodiscard]] V0Actions AsActions() const;
 
   // Constructs a Tx Power V0 data element
   explicit V0DataElement(TxPower tx_power);
+  // Constructs a Tx Power V0 data element
+  explicit V0DataElement(Psm psm);
+  // Constructs a Device Info V0 data element
+  explicit V0DataElement(DeviceInfo device_info);
   // Constructs an Actions V0 data element
   explicit V0DataElement(V0Actions actions);
 
diff --git a/nearby/presence/np_cpp_ffi/nearby_protocol.cc b/nearby/presence/np_cpp_ffi/nearby_protocol.cc
index 471cbea..35805c4 100644
--- a/nearby/presence/np_cpp_ffi/nearby_protocol.cc
+++ b/nearby/presence/np_cpp_ffi/nearby_protocol.cc
@@ -480,6 +480,16 @@
       np_ffi::internal::np_ffi_V0DataElement_into_TX_POWER(v0_data_element_));
 }
 
+Psm V0DataElement::AsPsm() const {
+  return Psm(
+      np_ffi::internal::np_ffi_V0DataElement_into_PSM(v0_data_element_));
+}
+
+DeviceInfo V0DataElement::AsDeviceInfo() const {
+  return DeviceInfo(
+      np_ffi::internal::np_ffi_V0DataElement_into_DeviceInfo(v0_data_element_));
+}
+
 V0Actions V0DataElement::AsActions() const {
   auto internal =
       np_ffi::internal::np_ffi_V0DataElement_into_ACTIONS(v0_data_element_);
@@ -491,6 +501,15 @@
       np_ffi::internal::np_ffi_TxPower_into_V0DataElement(tx_power.tx_power_);
 }
 
+V0DataElement::V0DataElement(Psm psm) {
+  v0_data_element_ = np_ffi::internal::np_ffi_Psm_into_V0DataElement(psm.psm_);
+}
+
+V0DataElement::V0DataElement(DeviceInfo device_info) {
+  v0_data_element_ = np_ffi::internal::np_ffi_DeviceInfo_into_V0DataElement(
+      device_info.device_info_);
+}
+
 V0DataElement::V0DataElement(V0Actions actions) {
   v0_data_element_ =
       np_ffi::internal::np_ffi_V0Actions_into_V0DataElement(actions.actions_);
@@ -501,12 +520,12 @@
 }
 
 bool V0Actions::HasAction(ActionType action) const {
-  return np_ffi::internal::np_ffi_V0Actions_has_action(actions_, action);
+  return np_ffi::internal::np_ffi_V0Actions_has_action(actions_, action.action_type_);
 }
 
 absl::Status V0Actions::TrySetAction(ActionType action, bool value) {
   auto result =
-      np_ffi::internal::np_ffi_V0Actions_set_action(actions_, action, value);
+      np_ffi::internal::np_ffi_V0Actions_set_action(actions_, action.action_type_, value);
   auto kind = np_ffi::internal::np_ffi_SetV0ActionResult_kind(result);
   switch (kind) {
     case np_ffi::internal::SetV0ActionResultKind::Success: {
@@ -547,6 +566,62 @@
   }
 }
 
+uint16_t Psm::GetAsU16() const {
+    return np_ffi::internal::np_ffi_Psm_as_unsigned_u16(psm_);
+}
+
+absl::StatusOr<Psm> Psm::TryBuildFromU16(uint16_t value) {
+  auto result =  np_ffi::internal::np_ffi_Psm_build_from_u16(value);
+  auto kind = np_ffi::internal::np_ffi_BuildPsmResult_kind(result);
+  switch (kind) {
+    case np_ffi::internal::BuildPsmResultKind::Success: {
+      return Psm(np_ffi::internal::np_ffi_BuildPsmResult_into_SUCCESS(result));
+    }
+    case np_ffi::internal::BuildPsmResultKind::OutOfRange: {
+      return absl::InvalidArgumentError(
+          "Could not build a PSM for the requested byte array.");
+    }
+  }
+}
+
+uint8_t DeviceInfo::GetDeviceType() const { return device_info_.device_type; }
+
+bool DeviceInfo::IsNameTruncated() const {
+  return device_info_.is_name_truncated;
+}
+
+np_ffi::internal::OptionalByteBuffer<9> DeviceInfo::GetDeviceName() const {
+  return device_info_.device_name;
+}
+
+DeviceInfo DeviceInfo::Build(
+    uint8_t device_type, bool is_name_truncated,
+    np_ffi::internal::OptionalByteBuffer<9> device_name) {
+  return DeviceInfo(np_ffi::internal::DeviceInfo{
+      .device_type = device_type,
+      .is_name_truncated = is_name_truncated,
+      .device_name = device_name,
+  });
+}
+
+uint8_t ActionType::GetAsU8() const {
+    return np_ffi::internal::np_ffi_ActionType_as_unsigned_byte(action_type_);
+}
+
+absl::StatusOr<ActionType> ActionType::TryBuildFromU8(uint8_t value) {
+  auto result =  np_ffi::internal::np_ffi_ActionType_build_from_unsigned_byte(value);
+  auto kind = np_ffi::internal::np_ffi_BuildActionTypeResult_kind(result);
+  switch (kind) {
+    case np_ffi::internal::BuildActionTypeResultKind::Success: {
+      return ActionType(np_ffi::internal::np_ffi_BuildActionTypeResult_into_SUCCESS(result));
+    }
+    case np_ffi::internal::BuildActionTypeResultKind::OutOfRange: {
+      return absl::InvalidArgumentError(
+          "Could not build an ActionType for the requested byte array.");
+    }
+  }
+}
+
 // This is called after all references to the shared_ptr have gone out of
 // scope
 auto DeallocateV1Adv(
diff --git a/nearby/presence/np_cpp_ffi/sample/main.cc b/nearby/presence/np_cpp_ffi/sample/main.cc
index 81c2cc8..0c4dcc3 100644
--- a/nearby/presence/np_cpp_ffi/sample/main.cc
+++ b/nearby/presence/np_cpp_ffi/sample/main.cc
@@ -235,6 +235,31 @@
                 << "\n";
       return;
     }
+    case nearby_protocol::V0DataElementKind::Psm: {
+      std::cout << "\t\t\tDE Type is Psm\n";
+      auto psm = de.AsPsm();
+      std::cout << "\t\t\tPSM: " << std::bitset<16>(psm.GetAsU16())
+                << "\n";
+      return;
+    }
+    case nearby_protocol::V0DataElementKind::DeviceInfo: {
+      std::cout << "\t\t\tDE Type is DeviceInfo\n";
+      auto device_info = de.AsDeviceInfo();
+      std::cout << "\t\t\tDeviceType: "
+                << static_cast<unsigned>(device_info.GetDeviceType()) << "\n";
+      std::cout << "\t\t\tIsNameTruncated: " << device_info.IsNameTruncated()
+                << "\n";
+      auto device_name = device_info.GetDeviceName();
+      if (device_name.tag ==
+          np_ffi::internal::OptionalByteBuffer<9>::Tag::Present) {
+        std::cout << "\t\t\tDeviceName: "
+                  << absl::BytesToHexString(nearby_protocol::ByteBuffer<9>(
+                                                device_name.present._0)
+                                                .ToString())
+                  << "\n";
+      }
+      return;
+    }
   }
 }
 
diff --git a/nearby/presence/np_cpp_ffi/tests/v0_unencrypted_deserialization_tests.cc b/nearby/presence/np_cpp_ffi/tests/v0_unencrypted_deserialization_tests.cc
index 59d6b69..6299322 100644
--- a/nearby/presence/np_cpp_ffi/tests/v0_unencrypted_deserialization_tests.cc
+++ b/nearby/presence/np_cpp_ffi/tests/v0_unencrypted_deserialization_tests.cc
@@ -137,13 +137,13 @@
   auto actions = de.AsActions();
   ASSERT_EQ(actions.GetAsU32(), 0x40400000U);
 
-  ASSERT_TRUE(actions.HasAction(nearby_protocol::ActionType::CrossDevSdk));
-  ASSERT_TRUE(actions.HasAction(nearby_protocol::ActionType::NearbyShare));
+  ASSERT_TRUE(actions.HasAction(nearby_protocol::ActionType::TryBuildFromU8(1).value()));
+  ASSERT_TRUE(actions.HasAction(nearby_protocol::ActionType::TryBuildFromU8(9).value()));
 
-  ASSERT_FALSE(actions.HasAction(nearby_protocol::ActionType::ActiveUnlock));
+  ASSERT_FALSE(actions.HasAction(nearby_protocol::ActionType::TryBuildFromU8(8).value()));
   ASSERT_FALSE(
-      actions.HasAction(nearby_protocol::ActionType::InstantTethering));
-  ASSERT_FALSE(actions.HasAction(nearby_protocol::ActionType::PhoneHub));
+      actions.HasAction(nearby_protocol::ActionType::TryBuildFromU8(10).value()));
+  ASSERT_FALSE(actions.HasAction(nearby_protocol::ActionType::TryBuildFromU8(11).value()));
 }
 
 TEST_F(NpCppTest, V0MultipleDataElements) {
diff --git a/nearby/presence/np_cpp_ffi/tests/v0_unencrypted_serialization_tests.cc b/nearby/presence/np_cpp_ffi/tests/v0_unencrypted_serialization_tests.cc
index ca28233..4ae2124 100644
--- a/nearby/presence/np_cpp_ffi/tests/v0_unencrypted_serialization_tests.cc
+++ b/nearby/presence/np_cpp_ffi/tests/v0_unencrypted_serialization_tests.cc
@@ -28,24 +28,6 @@
   ASSERT_FALSE(out_of_range_result.ok());
 }
 
-TEST_F(NpCppTest, V0UnencryptedActionFlavorMustMatch) {
-  auto actions = nearby_protocol::V0Actions::BuildNewZeroed(
-      nearby_protocol::AdvertisementBuilderKind::Public);
-
-  // Try to set an encrypted-only action.
-  auto mismatch_result =
-      actions.TrySetAction(nearby_protocol::ActionType::InstantTethering, true);
-  ASSERT_FALSE(mismatch_result.ok());
-  // Verify that nothing changed about the actions.
-  ASSERT_EQ(actions.GetAsU32(), 0u);
-
-  // Try again, but with a plaintext-compatible action.
-  auto success_result =
-      actions.TrySetAction(nearby_protocol::ActionType::NearbyShare, true);
-  ASSERT_TRUE(success_result.ok());
-  ASSERT_TRUE(actions.HasAction(nearby_protocol::ActionType::NearbyShare));
-}
-
 // Corresponds to V0DeserializeSingleDataElementTxPower
 TEST_F(NpCppTest, V0SerializeSingleDataElementTxPower) {
   auto adv_builder = nearby_protocol::V0AdvertisementBuilder::CreatePublic();
@@ -94,10 +76,10 @@
       nearby_protocol::AdvertisementBuilderKind::Public);
 
   ASSERT_TRUE(
-      actions.TrySetAction(nearby_protocol::ActionType::NearbyShare, true)
+      actions.TrySetAction(nearby_protocol::ActionType::TryBuildFromU8(9).value(), true)
           .ok());
   ASSERT_TRUE(
-      actions.TrySetAction(nearby_protocol::ActionType::CrossDevSdk, true)
+      actions.TrySetAction(nearby_protocol::ActionType::TryBuildFromU8(1).value(), true)
           .ok());
 
   auto de = nearby_protocol::V0DataElement(actions);
diff --git a/nearby/presence/np_ffi_core/src/common.rs b/nearby/presence/np_ffi_core/src/common.rs
index 53d7d1e..5b98d77 100644
--- a/nearby/presence/np_ffi_core/src/common.rs
+++ b/nearby/presence/np_ffi_core/src/common.rs
@@ -21,6 +21,8 @@
 use handle_map::HandleNotPresentError;
 use lock_adapter::stdlib::{RwLock, RwLockWriteGuard};
 use lock_adapter::RwLock as _;
+use np_adv::legacy::data_elements::psm::PsmDataElement;
+use np_adv_dynamic::legacy::ToBoxedSerializeDataElement;
 
 const MAX_HANDLES: u32 = u32::MAX - 1;
 
@@ -185,8 +187,10 @@
 // TODO: Once generic const exprs are stabilized,
 // we could instead make N into a compile-time u8.
 pub struct ByteBuffer<const N: usize> {
-    len: u8,
-    bytes: [u8; N],
+    /// The number of bytes in the buffer.
+    pub len: u8,
+    /// The bytes in the buffer.
+    pub bytes: [u8; N],
 }
 
 impl<const N: usize> Default for ByteBuffer<N> {
@@ -292,11 +296,12 @@
 }
 
 /// A FFI safe wrapper of an optional fixed-size byte array.
-#[derive(Clone)]
+#[derive(Clone, Default)]
 #[cfg_attr(any(test, feature = "testing"), derive(Debug, PartialEq, Eq, arbitrary::Arbitrary))]
 #[repr(C)]
 pub enum OptionalFixedSizeArray<const N: usize> {
     /// The optional fixed-size array isn't specified.
+    #[default]
     NotSpecified,
     /// The optional fixed-size array is present.
     Present(FixedSizeArray<N>),
@@ -312,12 +317,6 @@
     }
 }
 
-impl<const N: usize> Default for OptionalFixedSizeArray<N> {
-    fn default() -> Self {
-        Self::NotSpecified
-    }
-}
-
 impl<const N: usize> OptionalFixedSizeArray<N> {
     /// Attempts to cast `self` to the `Present` variant,
     /// returning `None` in the case where the passed value is of a different enum variant.
@@ -358,6 +357,56 @@
     }
 }
 
+/// Discriminant for [`OptionalByteBuffer`].
+#[derive(Copy, Clone)]
+#[repr(u8)]
+pub enum OptionalByteBufferKind {
+    /// The optional byte buffer isn't specified.
+    NotSpecified = 0,
+    /// The optional byte buffer is present.
+    Present = 1,
+}
+
+/// A FFI safe wrapper of an optional byte buffer.
+#[derive(Clone, Default)]
+#[cfg_attr(any(test, feature = "testing"), derive(Debug, PartialEq, Eq, arbitrary::Arbitrary))]
+#[repr(C)]
+pub enum OptionalByteBuffer<const N: usize> {
+    /// The optional byte buffer isn't specified.
+    #[default]
+    NotSpecified,
+    /// The optional byte buffer is present.
+    Present(ByteBuffer<N>),
+}
+
+impl<const N: usize> FfiEnum for OptionalByteBuffer<N> {
+    type Kind = OptionalByteBufferKind;
+    fn kind(&self) -> Self::Kind {
+        match self {
+            Self::NotSpecified => OptionalByteBufferKind::NotSpecified,
+            Self::Present(_) => OptionalByteBufferKind::Present,
+        }
+    }
+}
+
+impl<const N: usize> OptionalByteBuffer<N> {
+    /// Attempts to cast `self` to the `Present` variant,
+    /// returning `None` in the case where the passed value is of a different enum variant.
+    pub fn into_present(self) -> Option<ByteBuffer<N>> {
+        match self {
+            Self::NotSpecified => None,
+            Self::Present(x) => Some(x),
+        }
+    }
+    /// Returns true iff this `OptionalByteBuffer` has contents.
+    pub fn is_present(&self) -> bool {
+        match self {
+            Self::NotSpecified => false,
+            Self::Present(_) => true,
+        }
+    }
+}
+
 pub(crate) type CryptoRngImpl = <CryptoProviderImpl as CryptoProvider>::CryptoRng;
 
 pub(crate) struct LazyInitCryptoRng {
@@ -471,3 +520,113 @@
         np_adv::shared_data::TxPower::try_from(value.as_i8()).map_err(|_| InvalidStackDataStructure)
     }
 }
+
+/// Discriminant for `BuildPsmResult`.
+#[repr(u8)]
+#[derive(Clone, Copy)]
+pub enum BuildPsmResultKind {
+    /// The PSM should be within two bytes.
+    OutOfRange = 0,
+    /// The PSM was in range,
+    /// and so a `Psm` struct was constructed.
+    Success = 1,
+}
+
+/// Result type for attempting to construct a
+/// PSM from two bytes.
+#[repr(C)]
+#[allow(missing_docs)]
+pub enum BuildPsmResult {
+    OutOfRange,
+    Success(Psm),
+}
+
+impl FfiEnum for BuildPsmResult {
+    type Kind = BuildPsmResultKind;
+    fn kind(&self) -> Self::Kind {
+        match self {
+            Self::OutOfRange => BuildPsmResultKind::OutOfRange,
+            Self::Success(_) => BuildPsmResultKind::Success,
+        }
+    }
+}
+
+impl BuildPsmResult {
+    declare_enum_cast! {into_success, Success, Psm}
+}
+
+/// Representation of a PSM for L2CAP,
+#[derive(Clone, Copy)]
+#[cfg_attr(any(test, feature = "testing"), derive(Debug, PartialEq, Eq, arbitrary::Arbitrary))]
+#[repr(C)]
+pub struct Psm {
+    pub(crate) value: u16,
+}
+
+impl Psm {
+    /// Constructs a new PSM from the given unsigned two-byte value.
+    pub fn build_from_unsigned_bytes(psm: u16) -> BuildPsmResult {
+        BuildPsmResult::Success(Self { value: psm })
+    }
+
+    /// Returns the PSM value.
+    pub fn as_u16(&self) -> u16 {
+        self.value
+    }
+}
+
+impl TryFrom<Psm> for ToBoxedSerializeDataElement {
+    type Error = InvalidStackDataStructure;
+
+    fn try_from(psm: Psm) -> Result<Self, Self::Error> {
+        Ok(ToBoxedSerializeDataElement::Plaintext(Box::new(PsmDataElement { value: psm.value })))
+    }
+}
+
+/// Represents the contents of a Device Info DE containing the type and name of a device, as well as flag
+/// indicating whether the device name is truncated.
+///
+/// This representation is stable, and so you may directly
+/// reference this struct's fields if you wish.
+#[derive(Clone)]
+#[cfg_attr(any(test, feature = "testing"), derive(Debug, PartialEq, Eq, arbitrary::Arbitrary))]
+#[repr(C)]
+pub struct DeviceInfo {
+    /// The numerical value corresponding to a given
+    /// form factor, or "0" for the case where the
+    /// broadcasting device doesn't know what kind
+    /// of device type it is.
+    ///
+    /// Consult the Nearby spec for values
+    /// corresponding to particular form factors.
+    pub device_type: u8,
+    /// Boolean flag indicating whether the device name is truncated.
+    pub is_name_truncated: bool,
+    /// An optional byte array representing the name of the broadcasting
+    /// device. The maximum size of this device name array is 9 bytes.
+    pub device_name: OptionalByteBuffer<9>,
+}
+
+impl TryFrom<DeviceInfo> for ToBoxedSerializeDataElement {
+    type Error = InvalidStackDataStructure;
+
+    fn try_from(core_device_info: DeviceInfo) -> Result<Self, Self::Error> {
+        let device_type = np_adv::shared_data::DeviceType::from_repr(core_device_info.device_type)
+            .ok_or(InvalidStackDataStructure)?;
+
+        let name_slice = match core_device_info.device_name.into_present() {
+            Some(buffer) => buffer.as_slice()?.to_vec(),
+            None => Vec::new(),
+        };
+
+        let device_info = np_adv::shared_data::DeviceInfo::try_from((
+            device_type,
+            core_device_info.is_name_truncated,
+            name_slice.as_slice(),
+        ))
+        .map_err(|_| InvalidStackDataStructure)?;
+        Ok(ToBoxedSerializeDataElement::Plaintext(Box::new(
+            np_adv::legacy::data_elements::device_info::DeviceInfoDataElement { device_info },
+        )))
+    }
+}
diff --git a/nearby/presence/np_ffi_core/src/deserialize/v1.rs b/nearby/presence/np_ffi_core/src/deserialize/v1.rs
index dec454c..5ee203b 100644
--- a/nearby/presence/np_ffi_core/src/deserialize/v1.rs
+++ b/nearby/presence/np_ffi_core/src/deserialize/v1.rs
@@ -758,6 +758,8 @@
     CapabilitiesMalformed = 12,
     /// There was a Requirements DE, but the payload was malformed.
     RequirementsMalformed = 13,
+    /// There was a DeviceInfo DE, but the payload was malformed.
+    DeviceInfoMalformed = 14,
 }
 
 /// Representation of the result of attempting to deserialize
@@ -790,6 +792,7 @@
     MediaDeduplicationIdWrongLength,
     CapabilitiesMalformed,
     RequirementsMalformed,
+    DeviceInfoMalformed,
 }
 
 impl FfiEnum for V1DataElementDeserializationResult {
@@ -828,6 +831,9 @@
             Self::RequirementsMalformed => {
                 V1DataElementDeserializationResultKind::RequirementsMalformed
             }
+            Self::DeviceInfoMalformed => {
+                V1DataElementDeserializationResultKind::DeviceInfoMalformed
+            }
         }
     }
 }
@@ -945,6 +951,12 @@
                 ))
             }
             Ok(DeserializedGoogleDE::Requirements(Err(_))) => V1DataElementDeserializationResult::RequirementsMalformed,
+            Ok(DeserializedGoogleDE::DeviceInfo(Ok(device_info))) => {
+                V1DataElementDeserializationResult::Success(V1DataElement::DeviceInfo(
+                    device_info.into(),
+                ))
+            }
+            Ok(DeserializedGoogleDE::DeviceInfo(Err(_))) => V1DataElementDeserializationResult::DeviceInfoMalformed,
             Err(_) => V1DataElementDeserializationResult::Success(V1DataElement::Generic(self)),
         })
     }
@@ -1044,3 +1056,23 @@
         Self { requirements }
     }
 }
+
+impl From<np_adv::extended::data_elements::DeviceInfoDataElement> for DeviceInfo {
+    fn from(de: np_adv::extended::data_elements::DeviceInfoDataElement) -> Self {
+        let name_slice = de.device_info.device_name();
+        let mut name_bytes = [0; 9];
+        let len = name_slice.len().min(name_bytes.len());
+        #[allow(clippy::expect_used)]
+        name_bytes
+            .get_mut(..len)
+            .expect("slice length is within bounds")
+            .copy_from_slice(name_slice.get(..len).expect("slice length is within bounds"));
+        let device_name =
+            OptionalByteBuffer::Present(ByteBuffer { len: len as u8, bytes: name_bytes });
+        Self {
+            device_type: de.device_info.device_type() as u8,
+            is_name_truncated: de.device_info.name_truncated(),
+            device_name,
+        }
+    }
+}
diff --git a/nearby/presence/np_ffi_core/src/serialize/v1.rs b/nearby/presence/np_ffi_core/src/serialize/v1.rs
index 498194e..e3ba107 100644
--- a/nearby/presence/np_ffi_core/src/serialize/v1.rs
+++ b/nearby/presence/np_ffi_core/src/serialize/v1.rs
@@ -758,6 +758,70 @@
     }
 }
 
+/// The result of attempting to serialize capabilities
+/// represented as a bitmap into a DE.
+#[repr(C)]
+#[allow(missing_docs)]
+pub enum SerializeDeviceInfoResult {
+    Success(GenericV1DataElement),
+    DeviceInfoMalformed,
+}
+
+impl FfiEnum for SerializeDeviceInfoResult {
+    type Kind = SerializeDeviceInfoResultKind;
+    fn kind(&self) -> Self::Kind {
+        match self {
+            Self::Success(_) => SerializeDeviceInfoResultKind::Success,
+            Self::DeviceInfoMalformed => SerializeDeviceInfoResultKind::DeviceInfoMalformed,
+        }
+    }
+}
+
+impl SerializeDeviceInfoResult {
+    declare_enum_cast! {into_success, Success, GenericV1DataElement }
+}
+
+/// Discriminant for `SerializeCapabilitiesBitmapResult`
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[repr(u8)]
+pub enum SerializeDeviceInfoResultKind {
+    /// The attempt succeeded. The wrapped data element
+    /// may be obtained via
+    /// `SerializeCapabilitiesBitmapResult#into_success`.
+    Success = 0,
+    /// The attempt failed because reserved bits were set.
+    DeviceInfoMalformed = 1,
+}
+
+impl DeviceInfo {
+    /// Attempts to serialize this Device Info to a generic V1 data element.
+    pub fn try_serialize_v1(&self) -> SerializeDeviceInfoResult {
+        let result: Result<GenericV1DataElement, ()> = (|| {
+            let device_type =
+                np_adv::shared_data::DeviceType::from_repr(self.device_type).ok_or(())?;
+
+            let name_slice = match &self.device_name {
+                OptionalByteBuffer::Present(buffer) => buffer.as_slice().map_err(|_| ())?,
+                OptionalByteBuffer::NotSpecified => &[],
+            };
+
+            let device_info = np_adv::shared_data::DeviceInfo::try_from((
+                device_type,
+                self.is_name_truncated,
+                name_slice,
+            ))
+            .map_err(|_| ())?;
+            let de = np_adv::extended::data_elements::DeviceInfoDataElement::from(device_info);
+            Ok(GenericV1DataElement::from_write_de(&de))
+        })();
+
+        match result {
+            Ok(de) => SerializeDeviceInfoResult::Success(de),
+            Err(_) => SerializeDeviceInfoResult::DeviceInfoMalformed,
+        }
+    }
+}
+
 impl ContextSyncSeqNum {
     /// Attempts to serialize this Context Sync sequence number to a generic
     /// V1 data element.
@@ -1026,6 +1090,30 @@
         assert_eq!(result.kind(), SerializeRequirementsBitmapResultKind::ReservedBitsSet);
     }
 
+    #[test]
+    fn test_serialize_valid_device_info() {
+        let device_name = OptionalByteBuffer::Present(ByteBuffer { len: 9, bytes: [1u8; 9] });
+        let device_info = DeviceInfo { device_type: 1, is_name_truncated: false, device_name };
+        let result = device_info.try_serialize_v1();
+        assert_eq!(result.kind(), SerializeDeviceInfoResultKind::Success);
+    }
+
+    #[test]
+    fn test_serialize_device_info_name_too_short() {
+        let mut name_bytes = [0u8; 9];
+        let name_slice = b"abcd"; // 4 bytes, which is less than the minimum of 5.
+        #[allow(clippy::indexing_slicing)]
+        name_bytes[..name_slice.len()].copy_from_slice(name_slice);
+
+        let device_name = OptionalByteBuffer::Present(ByteBuffer {
+            len: name_slice.len() as u8,
+            bytes: name_bytes,
+        });
+        let device_info = DeviceInfo { device_type: 1, is_name_truncated: false, device_name };
+        let result = device_info.try_serialize_v1();
+        assert_eq!(result.kind(), SerializeDeviceInfoResultKind::DeviceInfoMalformed);
+    }
+
     #[cfg(not(feature = "nocrypto"))]
     fn state_is_advertisement_building(adv_builder_state: &V1AdvertisementBuilderState) -> bool {
         matches!(adv_builder_state, V1AdvertisementBuilderState::Advertisement(_))
diff --git a/nearby/presence/np_ffi_core/src/v0.rs b/nearby/presence/np_ffi_core/src/v0.rs
index d87cfe8..6f93d63 100644
--- a/nearby/presence/np_ffi_core/src/v0.rs
+++ b/nearby/presence/np_ffi_core/src/v0.rs
@@ -15,14 +15,15 @@
 //! Common externally-acessible V0 constructs for both of the
 //! serialization+deserialization flows.
 
-use crate::common::{InvalidStackDataStructure, TxPower};
+use crate::common::{
+    ByteBuffer, DeviceInfo, InvalidStackDataStructure, OptionalByteBuffer, Psm, TxPower,
+};
 use crate::serialize::v0::AdvertisementBuilderKind;
 use crate::utils::FfiEnum;
 use np_adv::{
     legacy::data_elements::actions::ActionsDataElement,
     legacy::{data_elements as np_adv_de, Ciphertext, PacketFlavorEnum, Plaintext},
 };
-use strum::IntoEnumIterator;
 
 /// Discriminant for `V0DataElement`.
 #[repr(u8)]
@@ -35,6 +36,14 @@
     /// The associated payload may be obtained via
     /// `V0DataElement#into_actions`.
     Actions = 2,
+    /// The PSM data-element.
+    /// The associated payload may be obtained via
+    /// `V0DataElement#into_psm`.
+    Psm = 3,
+    /// The DeviceInfo data-element.
+    /// The associated payload may be obtained via
+    /// 'V0DataElement#into_device_info`.
+    DeviceInfo = 4,
 }
 
 /// Representation of a V0 data element.
@@ -44,6 +53,8 @@
 pub enum V0DataElement {
     TxPower(TxPower),
     Actions(V0Actions),
+    Psm(Psm),
+    DeviceInfo(DeviceInfo),
 }
 
 impl TryFrom<V0DataElement> for np_adv_dynamic::legacy::ToBoxedSerializeDataElement {
@@ -52,6 +63,8 @@
         match de {
             V0DataElement::TxPower(x) => x.try_into(),
             V0DataElement::Actions(x) => x.try_into(),
+            V0DataElement::Psm(x) => x.try_into(),
+            V0DataElement::DeviceInfo(x) => x.try_into(),
         }
     }
 }
@@ -64,6 +77,8 @@
         match de {
             DeserializedDataElement::Actions(x) => V0DataElement::Actions(x.into()),
             DeserializedDataElement::TxPower(x) => V0DataElement::TxPower(x.into()),
+            DeserializedDataElement::Psm(x) => V0DataElement::Psm(x.into()),
+            DeserializedDataElement::DeviceInfo(x) => V0DataElement::DeviceInfo(x.into()),
         }
     }
 }
@@ -74,6 +89,8 @@
         match self {
             V0DataElement::Actions(_) => V0DataElementKind::Actions,
             V0DataElement::TxPower(_) => V0DataElementKind::TxPower,
+            V0DataElement::Psm(_) => V0DataElementKind::Psm,
+            V0DataElement::DeviceInfo(_) => V0DataElementKind::DeviceInfo,
         }
     }
 }
@@ -81,6 +98,8 @@
 impl V0DataElement {
     declare_enum_cast! {into_tx_power, TxPower, TxPower}
     declare_enum_cast! {into_actions, Actions, V0Actions}
+    declare_enum_cast! {into_psm, Psm, Psm}
+    declare_enum_cast! {into_device_info, DeviceInfo, DeviceInfo}
 }
 
 impl From<np_adv_de::tx_power::TxPowerDataElement> for TxPower {
@@ -89,6 +108,32 @@
     }
 }
 
+impl From<np_adv_de::psm::PsmDataElement> for Psm {
+    fn from(de: np_adv_de::psm::PsmDataElement) -> Self {
+        Self { value: de.value }
+    }
+}
+
+impl From<np_adv_de::device_info::DeviceInfoDataElement> for DeviceInfo {
+    fn from(de: np_adv_de::device_info::DeviceInfoDataElement) -> Self {
+        let name_slice = de.device_info.device_name();
+        let mut name_bytes = [0; 9];
+        let len = name_slice.len().min(name_bytes.len());
+        #[allow(clippy::expect_used)]
+        name_bytes
+            .get_mut(..len)
+            .expect("slice length is within bounds")
+            .copy_from_slice(name_slice.get(..len).expect("slice length is within bounds"));
+        let device_name =
+            OptionalByteBuffer::Present(ByteBuffer { len: len as u8, bytes: name_bytes });
+        Self {
+            device_type: de.device_info.device_type() as u8,
+            is_name_truncated: de.device_info.name_truncated(),
+            device_name,
+        }
+    }
+}
+
 /// Representation of the Actions DE in V0.
 #[derive(Clone, Copy)]
 #[repr(C)]
@@ -137,18 +182,12 @@
     }
 }
 
-#[derive(Clone, Copy, strum_macros::EnumIter)]
+#[derive(Clone, Copy)]
 #[allow(missing_docs)]
-#[repr(u8)]
-/// The possible boolean action types which can be present in an Actions data element
-pub enum ActionType {
-    CrossDevSdk = 1,
-    CallTransfer = 4,
-    ActiveUnlock = 8,
-    NearbyShare = 9,
-    InstantTethering = 10,
-    PhoneHub = 11,
-}
+#[repr(C)]
+/// Action data element
+pub struct ActionType(u8);
+const MAX_V0_ACTION_ID: u8 = 31;
 
 #[derive(Clone, Copy, Debug)]
 /// The given int is out of range for conversion.
@@ -160,11 +199,49 @@
     }
 }
 
+/// Discriminant for `BuildActionTypeResult`.
+#[repr(u8)]
+#[derive(Clone, Copy)]
+pub enum BuildActionTypeResultKind {
+    /// The action type was outside the
+    /// allowed 0 to 31 range.
+    OutOfRange = 0,
+    /// The transmission power was in range,
+    /// and so a `TxPower` struct was constructed.
+    Success = 1,
+}
+
+/// Result type for attempting to construct a
+/// ActionType from an usigned byte.
+#[repr(C)]
+#[allow(missing_docs)]
+pub enum BuildActionTypeResult {
+    OutOfRange,
+    Success(ActionType),
+}
+
+impl FfiEnum for BuildActionTypeResult {
+    type Kind = BuildActionTypeResultKind;
+    fn kind(&self) -> Self::Kind {
+        match self {
+            Self::OutOfRange => BuildActionTypeResultKind::OutOfRange,
+            Self::Success(_) => BuildActionTypeResultKind::Success,
+        }
+    }
+}
+
+impl BuildActionTypeResult {
+    declare_enum_cast! {into_success, Success, ActionType}
+}
+
 impl TryFrom<u8> for ActionType {
     type Error = TryFromIntError;
     fn try_from(n: u8) -> Result<Self, Self::Error> {
-        // cast is safe since it's a repr(u8) unit enum
-        Self::iter().find(|t| *t as u8 == n).ok_or(TryFromIntError)
+        if n > MAX_V0_ACTION_ID {
+            Err(TryFromIntError)
+        } else {
+            Ok(ActionType(n))
+        }
     }
 }
 
@@ -174,51 +251,33 @@
         value: bool,
     ) -> np_adv_dynamic::legacy::ToBoxedActionElement {
         use np_adv_dynamic::legacy::ToBoxedActionElement;
-        match self {
-            Self::CrossDevSdk => ToBoxedActionElement::CrossDevSdk(value),
-            Self::CallTransfer => ToBoxedActionElement::CallTransfer(value),
-            Self::ActiveUnlock => ToBoxedActionElement::ActiveUnlock(value),
-            Self::NearbyShare => ToBoxedActionElement::NearbyShare(value),
-            Self::InstantTethering => ToBoxedActionElement::InstantTethering(value),
-            Self::PhoneHub => ToBoxedActionElement::PhoneHub(value),
+        ToBoxedActionElement { action: self.0, value }
+    }
+
+    /// Constructs a new ActionType from the given unsigned byte value.
+    pub fn build_from_unsigned_byte(action_type: u8) -> BuildActionTypeResult {
+        match action_type.try_into() {
+            Ok(action_type) => BuildActionTypeResult::Success(action_type),
+            Err(_) => BuildActionTypeResult::OutOfRange,
         }
     }
+
+    /// Returns the ActionType value.
+    pub fn as_u8(&self) -> u8 {
+        self.0
+    }
 }
 
 impl From<ActionType> for np_adv::legacy::data_elements::actions::ActionType {
     fn from(value: ActionType) -> Self {
-        match value {
-            ActionType::CrossDevSdk => {
-                np_adv::legacy::data_elements::actions::ActionType::CrossDevSdk
-            }
-            ActionType::CallTransfer => {
-                np_adv::legacy::data_elements::actions::ActionType::CallTransfer
-            }
-            ActionType::ActiveUnlock => {
-                np_adv::legacy::data_elements::actions::ActionType::ActiveUnlock
-            }
-            ActionType::NearbyShare => {
-                np_adv::legacy::data_elements::actions::ActionType::NearbyShare
-            }
-            ActionType::InstantTethering => {
-                np_adv::legacy::data_elements::actions::ActionType::InstantTethering
-            }
-            ActionType::PhoneHub => np_adv::legacy::data_elements::actions::ActionType::PhoneHub,
-        }
+        np_adv::legacy::data_elements::actions::ActionType(value.0)
     }
 }
 
 // ensure bidirectional mapping
 impl From<np_adv::legacy::data_elements::actions::ActionType> for ActionType {
     fn from(value: np_adv_de::actions::ActionType) -> Self {
-        match value {
-            np_adv_de::actions::ActionType::CrossDevSdk => ActionType::CrossDevSdk,
-            np_adv_de::actions::ActionType::CallTransfer => ActionType::CallTransfer,
-            np_adv_de::actions::ActionType::ActiveUnlock => ActionType::ActiveUnlock,
-            np_adv_de::actions::ActionType::NearbyShare => ActionType::NearbyShare,
-            np_adv_de::actions::ActionType::InstantTethering => ActionType::InstantTethering,
-            np_adv_de::actions::ActionType::PhoneHub => ActionType::PhoneHub,
-        }
+        ActionType(value.0)
     }
 }
 
@@ -239,19 +298,15 @@
     fn try_from(actions: V0Actions) -> Result<Self, InvalidStackDataStructure> {
         match actions {
             V0Actions::Plaintext(action_bits) => {
-                let bits =
-                    np_adv::legacy::data_elements::actions::ActionBits::<Plaintext>::try_from(
-                        action_bits.bitfield,
-                    )
-                    .map_err(|_| InvalidStackDataStructure)?;
+                let bits = np_adv::legacy::data_elements::actions::ActionBits::<Plaintext>::from(
+                    action_bits.bitfield,
+                );
                 Ok(bits.into())
             }
             V0Actions::Encrypted(action_bits) => {
-                let bits =
-                    np_adv::legacy::data_elements::actions::ActionBits::<Ciphertext>::try_from(
-                        action_bits.bitfield,
-                    )
-                    .map_err(|_| InvalidStackDataStructure)?;
+                let bits = np_adv::legacy::data_elements::actions::ActionBits::<Ciphertext>::from(
+                    action_bits.bitfield,
+                );
                 Ok(bits.into())
             }
         }
diff --git a/nearby/presence/np_ffi_core/src/v1.rs b/nearby/presence/np_ffi_core/src/v1.rs
index 14f07eb..53d31fa 100644
--- a/nearby/presence/np_ffi_core/src/v1.rs
+++ b/nearby/presence/np_ffi_core/src/v1.rs
@@ -16,7 +16,8 @@
 //! serialization+deserialization flows.
 
 use crate::common::{
-    ByteBuffer, FixedSizeArray, InvalidStackDataStructure, OptionalFixedSizeArray, TxPower,
+    ByteBuffer, DeviceInfo, FixedSizeArray, InvalidStackDataStructure, OptionalFixedSizeArray,
+    TxPower,
 };
 use crate::utils::FfiEnum;
 
@@ -129,6 +130,8 @@
     Capabilities = 9,
     /// A Requirements DE.
     Requirements = 10,
+    /// A Device Info DE.
+    DeviceInfo = 11,
 }
 
 /// A V1 data element, which may be a "generic" data element
@@ -149,6 +152,7 @@
     MediaDeduplicationId(MediaDeduplicationId),
     Capabilities(Capabilities),
     Requirements(Requirements),
+    DeviceInfo(DeviceInfo),
 }
 
 impl FfiEnum for V1DataElement {
@@ -166,6 +170,7 @@
             Self::MediaDeduplicationId(_) => V1DataElementKind::MediaDeduplicationId,
             Self::Capabilities(_) => V1DataElementKind::Capabilities,
             Self::Requirements(_) => V1DataElementKind::Requirements,
+            Self::DeviceInfo(_) => V1DataElementKind::DeviceInfo,
         }
     }
 }
@@ -182,6 +187,7 @@
     declare_enum_cast! {into_media_deduplication_id, MediaDeduplicationId, MediaDeduplicationId}
     declare_enum_cast! {into_capabilities, Capabilities, Capabilities}
     declare_enum_cast! {into_requirements, Requirements, Requirements}
+    declare_enum_cast! {into_device_info, DeviceInfo, DeviceInfo}
 }
 
 /// Represents the contents of a V1 Actions DE as a bitmap
@@ -420,11 +426,12 @@
 }
 
 /// An optional IPv4 or IPv6 address, represented by big-endian bytes.
+#[derive(Clone, Default)]
 #[repr(C)]
 #[cfg_attr(any(test, feature = "testing"), derive(Debug, PartialEq, Eq, arbitrary::Arbitrary))]
-#[derive(Clone)]
 pub enum OptionalIpAddress {
     /// No IP address specified.
+    #[default]
     NotPresent,
     /// An IPv4 address (4 bytes).
     IPv4(FixedSizeArray<4>),
@@ -432,12 +439,6 @@
     IPv6(FixedSizeArray<16>),
 }
 
-impl Default for OptionalIpAddress {
-    fn default() -> Self {
-        Self::NotPresent
-    }
-}
-
 impl FfiEnum for OptionalIpAddress {
     type Kind = OptionalIpAddressKind;
     fn kind(&self) -> Self::Kind {
diff --git a/nearby/presence/np_hkdf/src/v1_salt.rs b/nearby/presence/np_hkdf/src/v1_salt.rs
index dd4d44e..8f062a8 100644
--- a/nearby/presence/np_hkdf/src/v1_salt.rs
+++ b/nearby/presence/np_hkdf/src/v1_salt.rs
@@ -153,6 +153,7 @@
 
 impl DeType {
     /// A `const` equivalent to `From<u32>` since trait methods can't yet be const.
+    #[allow(clippy::panic)]
     pub const fn const_from(value: u32) -> Self {
         if value == FORBIDDEN_DE_TYPE_CODE {
             panic!("Invalid DeType.");
diff --git a/nearby/presence/np_java_ffi/Cargo.toml b/nearby/presence/np_java_ffi/Cargo.toml
index c16073d..db2b9af 100644
--- a/nearby/presence/np_java_ffi/Cargo.toml
+++ b/nearby/presence/np_java_ffi/Cargo.toml
@@ -12,6 +12,8 @@
 # The testing feature enables test-only APIs that implement native methods in
 # the test/ library.
 testing = ["np_ffi_core/testing"]
+rustcrypto = ["np_ffi_core/rustcrypto"]
+boringssl = ["np_ffi_core/boringssl"]
 
 [dependencies]
 array_view.workspace = true
diff --git a/nearby/presence/np_java_ffi/build.gradle.kts b/nearby/presence/np_java_ffi/build.gradle.kts
index 40f3845..e7d40c3 100644
--- a/nearby/presence/np_java_ffi/build.gradle.kts
+++ b/nearby/presence/np_java_ffi/build.gradle.kts
@@ -14,74 +14,65 @@
  * limitations under the License.
  */
 
-import net.ltgt.gradle.errorprone.errorprone;
+import net.ltgt.gradle.errorprone.errorprone
 
 plugins {
-  `java-library`
-  // For static analysis
-  id("net.ltgt.errorprone") version "4.0.0"
+    `java-library`
+    // For static analysis
+    id("net.ltgt.errorprone") version "4.3.0"
 }
 
 java {
-  // Gradle JUnit test finder doesn't support Java 21 class files.
-  sourceCompatibility = JavaVersion.VERSION_1_9
-  targetCompatibility = JavaVersion.VERSION_1_9
+    // Gradle JUnit test finder doesn't support Java 21 class files.
+    sourceCompatibility = JavaVersion.VERSION_1_9
+    targetCompatibility = JavaVersion.VERSION_1_9
 }
 
 repositories {
-  mavenCentral()
-  google()
+    mavenCentral()
+    google()
 }
 
 dependencies {
-  // Static analysis annnotations
-  implementation("androidx.annotation:annotation:1.6.0")
-  implementation("com.google.errorprone:error_prone_core:2.28.0")
-  implementation("org.checkerframework:checker-qual:3.45.0")
+    // Static analysis annnotations
+    implementation("androidx.annotation:annotation:1.6.0")
+    implementation("com.google.errorprone:error_prone_annotations:2.41.0")
+    errorprone("com.google.errorprone:error_prone_core:2.41.0")
+    implementation("org.checkerframework:checker-qual:3.45.0")
+    implementation("com.google.guava:failureaccess:1.0.3")
 
-  // Local dependencies
-  implementation(project(":cooperative_cleaner"))
+    // Local dependencies
+    implementation(project(":cooperative_cleaner"))
 
-  // JUnit Test Support
-  testImplementation("junit:junit:4.13")
-  testImplementation("com.google.truth:truth:1.1.4")
-  testImplementation("com.google.code.gson:gson:2.10.1")
-  testImplementation("org.mockito:mockito-core:5.+")
+    // JUnit Test Support
+    testImplementation("junit:junit:4.13")
+    testImplementation("com.google.truth:truth:1.1.4")
+    testImplementation("com.google.code.gson:gson:2.10.1")
+    testImplementation("org.mockito:mockito-core:5.+")
 }
 
 // Flattened directory layout
 sourceSets {
-  main {
-    java {
-      setSrcDirs(listOf("java"))
-    }
-  }
-  test {
-    java {
-      setSrcDirs(listOf("test"))
-    }
-  }
+    main { java { setSrcDirs(listOf("java")) } }
+    test { java { setSrcDirs(listOf("test")) } }
 }
 
 tasks.test {
-  useJUnit()
-  jvmArgs = mutableListOf(
-      // libnp_java_ffi.so
-      // This is the Cargo workspace's build output directory
-      "-Djava.library.path=$projectDir/../../target/debug",
-      // ByteBuddy agent for mocks
-      "-XX:+EnableDynamicAgentLoading"
-  )
+    useJUnit()
+    jvmArgs =
+            mutableListOf(
+                    // libnp_java_ffi.so
+                    // This is the Cargo workspace's build output directory
+                    "-Djava.library.path=$projectDir/../../target/debug",
+                    // ByteBuddy agent for mocks
+                    "-XX:+EnableDynamicAgentLoading"
+            )
 }
 
 buildscript {
-    repositories {
-        mavenCentral()
-    }
+    repositories { mavenCentral() }
     dependencies {
-        classpath("com.guardsquare:proguard-gradle:7.7.0") {
-            exclude("com.android.tools.build")
-        }
+        classpath("com.guardsquare:proguard-gradle:7.7.0") { exclude("com.android.tools.build") }
     }
 }
 
@@ -112,38 +103,39 @@
 }
 
 tasks.register<Test>("proguardedTest") {
-  useJUnit()
-  jvmArgs = mutableListOf(
-      // libnp_java_ffi.so
-      // This is the Cargo workspace's build output directory
-      "-Djava.library.path=$projectDir/../../target/debug",
-      // ByteBuddy agent for mocks
-      "-XX:+EnableDynamicAgentLoading"
-  )
-  // Ignore the usual built classes
-  classpath -= files("build/classes/java/main")
-  classpath -= files("build/classes/java/test")
-  // Use the proguarded classes instead
-  classpath += files("build/libs/unzippedproguardedtests")
-  dependsOn("unzipProguardedTestsJar")
-  // Exclude test-vector checks, since that
-  // uses reflection, which won't play nice with proguard.
-  exclude("**/*TestVectors*")
+    useJUnit()
+    jvmArgs =
+            mutableListOf(
+                    // libnp_java_ffi.so
+                    // This is the Cargo workspace's build output directory
+                    "-Djava.library.path=$projectDir/../../target/debug",
+                    // ByteBuddy agent for mocks
+                    "-XX:+EnableDynamicAgentLoading"
+            )
+    // Ignore the usual built classes
+    classpath -= files("build/classes/java/main")
+    classpath -= files("build/classes/java/test")
+    // Use the proguarded classes instead
+    classpath += files("build/libs/unzippedproguardedtests")
+    dependsOn("unzipProguardedTestsJar")
+    // Exclude test-vector checks, since that
+    // uses reflection, which won't play nice with proguard.
+    exclude("**/*TestVectors*")
 }
 
 tasks.withType<JavaCompile>().configureEach {
-  // Configure static analysis passes to match g3 if possible
-  options.errorprone {
-    error("CheckReturnValue")
-    error("UnnecessaryStaticImport")
-    error("WildcardImport")
-    error("RemoveUnusedImports")
-    error("ReturnMissingNullable")
-    error("FieldMissingNullable")
-    error("AnnotationPosition")
-    error("CheckedExceptionNotThrown")
-    error("NonFinalStaticField")
-    error("InvalidLink")
-    error("ThrowsUncheckedException")
-  }
+    // Configure static analysis passes to match g3 if possible
+    options.errorprone {
+        error("CheckReturnValue")
+        error("UnnecessaryStaticImport")
+        error("WildcardImport")
+        error("RemoveUnusedImports")
+        error("ReturnMissingNullable")
+        error("FieldMissingNullable")
+        error("AnnotationPosition")
+        error("CheckedExceptionNotThrown")
+        error("NonFinalStaticField")
+        error("InvalidLink")
+        error("ThrowsUncheckedException")
+    }
 }
diff --git a/nearby/presence/np_java_ffi/gradle/wrapper/gradle-wrapper.jar b/nearby/presence/np_java_ffi/gradle/wrapper/gradle-wrapper.jar
index 943f0cb..2c35211 100644
--- a/nearby/presence/np_java_ffi/gradle/wrapper/gradle-wrapper.jar
+++ b/nearby/presence/np_java_ffi/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/nearby/presence/np_java_ffi/gradle/wrapper/gradle-wrapper.properties b/nearby/presence/np_java_ffi/gradle/wrapper/gradle-wrapper.properties
index 171d876..bad7c24 100644
--- a/nearby/presence/np_java_ffi/gradle/wrapper/gradle-wrapper.properties
+++ b/nearby/presence/np_java_ffi/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,7 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
 networkTimeout=10000
+validateDistributionUrl=true
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/nearby/presence/np_java_ffi/gradlew b/nearby/presence/np_java_ffi/gradlew
index 79a61d4..f5feea6 100755
--- a/nearby/presence/np_java_ffi/gradlew
+++ b/nearby/presence/np_java_ffi/gradlew
@@ -15,6 +15,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
+# SPDX-License-Identifier: Apache-2.0
+#
 
 ##############################################################################
 #
@@ -55,7 +57,7 @@
 #       Darwin, MinGW, and NonStop.
 #
 #   (3) This script is generated from the Groovy template
-#       https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
 #       within the Gradle project.
 #
 #       You can find Gradle at https://github.com/gradle/gradle/.
@@ -83,10 +85,9 @@
 # This is normally unused
 # shellcheck disable=SC2034
 APP_BASE_NAME=${0##*/}
-APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
 
 # Use the maximum available, or set MAX_FD != -1 to use that value.
 MAX_FD=maximum
@@ -133,10 +134,13 @@
     fi
 else
     JAVACMD=java
-    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
 
 Please set the JAVA_HOME variable in your environment to match the
 location of your Java installation."
+    fi
 fi
 
 # Increase the maximum file descriptors if we can.
@@ -144,7 +148,7 @@
     case $MAX_FD in #(
       max*)
         # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
-        # shellcheck disable=SC3045
+        # shellcheck disable=SC2039,SC3045
         MAX_FD=$( ulimit -H -n ) ||
             warn "Could not query maximum file descriptor limit"
     esac
@@ -152,7 +156,7 @@
       '' | soft) :;; #(
       *)
         # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
-        # shellcheck disable=SC3045
+        # shellcheck disable=SC2039,SC3045
         ulimit -n "$MAX_FD" ||
             warn "Could not set maximum file descriptor limit to $MAX_FD"
     esac
@@ -197,11 +201,15 @@
     done
 fi
 
-# Collect all arguments for the java command;
-#   * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
-#     shell script including quotes and variable substitutions, so put them in
-#     double quotes to make sure that they get re-expanded; and
-#   * put everything else in single quotes, so that it's not re-expanded.
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
 
 set -- \
         "-Dorg.gradle.appname=$APP_BASE_NAME" \
diff --git a/nearby/presence/np_java_ffi/gradlew.bat b/nearby/presence/np_java_ffi/gradlew.bat
new file mode 100644
index 0000000..9b42019
--- /dev/null
+++ b/nearby/presence/np_java_ffi/gradlew.bat
@@ -0,0 +1,94 @@
+@rem

+@rem Copyright 2015 the original author or authors.

+@rem

+@rem Licensed under the Apache License, Version 2.0 (the "License");

+@rem you may not use this file except in compliance with the License.

+@rem You may obtain a copy of the License at

+@rem

+@rem      https://www.apache.org/licenses/LICENSE-2.0

+@rem

+@rem Unless required by applicable law or agreed to in writing, software

+@rem distributed under the License is distributed on an "AS IS" BASIS,

+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

+@rem See the License for the specific language governing permissions and

+@rem limitations under the License.

+@rem

+@rem SPDX-License-Identifier: Apache-2.0

+@rem

+

+@if "%DEBUG%"=="" @echo off

+@rem ##########################################################################

+@rem

+@rem  Gradle startup script for Windows

+@rem

+@rem ##########################################################################

+

+@rem Set local scope for the variables with windows NT shell

+if "%OS%"=="Windows_NT" setlocal

+

+set DIRNAME=%~dp0

+if "%DIRNAME%"=="" set DIRNAME=.

+@rem This is normally unused

+set APP_BASE_NAME=%~n0

+set APP_HOME=%DIRNAME%

+

+@rem Resolve any "." and ".." in APP_HOME to make it shorter.

+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi

+

+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.

+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"

+

+@rem Find java.exe

+if defined JAVA_HOME goto findJavaFromJavaHome

+

+set JAVA_EXE=java.exe

+%JAVA_EXE% -version >NUL 2>&1

+if %ERRORLEVEL% equ 0 goto execute

+

+echo. 1>&2

+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2

+echo. 1>&2

+echo Please set the JAVA_HOME variable in your environment to match the 1>&2

+echo location of your Java installation. 1>&2

+

+goto fail

+

+:findJavaFromJavaHome

+set JAVA_HOME=%JAVA_HOME:"=%

+set JAVA_EXE=%JAVA_HOME%/bin/java.exe

+

+if exist "%JAVA_EXE%" goto execute

+

+echo. 1>&2

+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2

+echo. 1>&2

+echo Please set the JAVA_HOME variable in your environment to match the 1>&2

+echo location of your Java installation. 1>&2

+

+goto fail

+

+:execute

+@rem Setup the command line

+

+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar

+

+

+@rem Execute Gradle

+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*

+

+:end

+@rem End local scope for the variables with windows NT shell

+if %ERRORLEVEL% equ 0 goto mainEnd

+

+:fail

+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of

+rem the _cmd.exe /c_ return code!

+set EXIT_CODE=%ERRORLEVEL%

+if %EXIT_CODE% equ 0 set EXIT_CODE=1

+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%

+exit /b %EXIT_CODE%

+

+:mainEnd

+if "%OS%"=="Windows_NT" endlocal

+

+:omega

diff --git a/nearby/presence/np_java_ffi/java/com/google/android/nearby/presence/rust/V0AdvertisementBuilder.java b/nearby/presence/np_java_ffi/java/com/google/android/nearby/presence/rust/V0AdvertisementBuilder.java
index b707df4..569f3d5 100644
--- a/nearby/presence/np_java_ffi/java/com/google/android/nearby/presence/rust/V0AdvertisementBuilder.java
+++ b/nearby/presence/np_java_ffi/java/com/google/android/nearby/presence/rust/V0AdvertisementBuilder.java
@@ -19,6 +19,7 @@
 import com.google.android.cooperativecleaner.CooperativeCleaner;
 import com.google.android.nearby.presence.rust.SerializationException.InsufficientSpaceException;
 import com.google.android.nearby.presence.rust.SerializationException.InvalidDataElementException;
+import com.google.android.nearby.presence.rust.V0DataElement.Psm;
 import com.google.android.nearby.presence.rust.V0DataElement.TxPower;
 import com.google.android.nearby.presence.rust.V0DataElement.V0Actions;
 import com.google.android.nearby.presence.rust.credential.V0BroadcastCredential;
@@ -118,6 +119,15 @@
       }
 
       @Override
+      public void visitPsm(Psm psm) {
+        try {
+          nativeAddPsmDataElement(psm);
+        } catch (InsufficientSpaceException ise) {
+          throw new SmuggledInsufficientSpaceException(ise);
+        }
+      }
+
+      @Override
       public void visitV0Actions(V0Actions actions) {
         try {
           nativeAddV0ActionsDataElement(actions);
@@ -166,6 +176,8 @@
     private native void nativeAddTxPowerDataElement(TxPower txPower)
         throws InsufficientSpaceException;
 
+    private native void nativeAddPsmDataElement(Psm psm) throws InsufficientSpaceException;
+
     private native void nativeAddV0ActionsDataElement(V0Actions v0Actions)
         throws InsufficientSpaceException;
 
diff --git a/nearby/presence/np_java_ffi/java/com/google/android/nearby/presence/rust/V0DataElement.java b/nearby/presence/np_java_ffi/java/com/google/android/nearby/presence/rust/V0DataElement.java
index 2c3c4ed..d1e80fa 100644
--- a/nearby/presence/np_java_ffi/java/com/google/android/nearby/presence/rust/V0DataElement.java
+++ b/nearby/presence/np_java_ffi/java/com/google/android/nearby/presence/rust/V0DataElement.java
@@ -31,6 +31,8 @@
   public interface Visitor {
     default void visitTxPower(TxPower txPower) {}
 
+    default void visitPsm(Psm psm) {}
+
     default void visitV0Actions(V0Actions v0Actions) {}
   }
 
@@ -65,6 +67,31 @@
     }
   }
 
+  /** Contains the PSM information. See the spec for more information. */
+  @UsedByNative
+  public static final class Psm extends V0DataElement {
+    @UsedByNative private final int psm;
+
+    public Psm(NpAdv npAdv, int psm) {
+      this(psm);
+      npAdv.ensureLoaded();
+    }
+
+    @UsedByNative
+    private Psm(int psm) {
+      this.psm = psm;
+    }
+
+    public int getPsm() {
+      return psm;
+    }
+
+    @Override
+    public void visit(Visitor v) {
+      v.visitPsm(this);
+    }
+  }
+
   /** Marker annotation/enum for V0 action values. */
   @IntDef({
     V0ActionType.CROSS_DEV_SDK,
diff --git a/nearby/presence/np_java_ffi/src/class/v0_advertisement_builder.rs b/nearby/presence/np_java_ffi/src/class/v0_advertisement_builder.rs
index 96d56ec..b07300f 100644
--- a/nearby/presence/np_java_ffi/src/class/v0_advertisement_builder.rs
+++ b/nearby/presence/np_java_ffi/src/class/v0_advertisement_builder.rs
@@ -12,12 +12,18 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+use crate::class::{
+    v0_data_element::{Psm, TxPower, V0Actions},
+    InsufficientSpaceException, InvalidDataElementException, InvalidHandleException,
+    LdtEncryptionException, NoSpaceLeftException, UnencryptedSizeException, V0BroadcastCredential,
+};
 use handle_map::{Handle, HandleLike};
 use jni::{
     objects::{JByteArray, JClass, JObject},
     sys::jlong,
     JNIEnv,
 };
+use np_ffi_core::common::BuildPsmResult;
 use np_ffi_core::{
     common::BuildTxPowerResult,
     serialize::v0::{
@@ -29,12 +35,6 @@
 };
 use pourover::{desc::ClassDesc, jni_method};
 
-use crate::class::{
-    v0_data_element::{TxPower, V0Actions},
-    InsufficientSpaceException, InvalidDataElementException, InvalidHandleException,
-    LdtEncryptionException, NoSpaceLeftException, UnencryptedSizeException, V0BroadcastCredential,
-};
-
 static V0_BUILDER_HANDLE_CLASS: ClassDesc = ClassDesc::new(
     "com/google/android/nearby/presence/rust/V0AdvertisementBuilder$V0BuilderHandle",
 );
@@ -180,6 +180,36 @@
 #[jni_method(
     package = "com.google.android.nearby.presence.rust",
     class = "V0AdvertisementBuilder.V0BuilderHandle",
+    method_name = "nativeAddPsmDataElement"
+)]
+extern "system" fn add_psm_de<'local>(
+    mut env: JNIEnv<'local>,
+    this: V0BuilderHandle<JObject<'local>>,
+    psm: Psm<JObject<'local>>,
+) {
+    let psm = match psm.get_as_core(&mut env) {
+        Ok(BuildPsmResult::Success(psm)) => psm,
+        Ok(BuildPsmResult::OutOfRange) => {
+            let _ = env
+                .new_string("PSM value out of range")
+                .map(|string| env.auto_local(string))
+                .and_then(|string| InvalidDataElementException::throw_new(&mut env, &string));
+            return;
+        }
+        Err(_jni_err) => {
+            // `crate jni` should have already thrown
+            return;
+        }
+    };
+    let Ok(()) = this.add_de(&mut env, V0DataElement::Psm(psm)) else {
+        // `crate jni` should have already thrown
+        return;
+    };
+}
+
+#[jni_method(
+    package = "com.google.android.nearby.presence.rust",
+    class = "V0AdvertisementBuilder.V0BuilderHandle",
     method_name = "nativeAddV0ActionsDataElement"
 )]
 extern "system" fn add_v0_actions_de<'local>(
diff --git a/nearby/presence/np_java_ffi/src/class/v0_data_element.rs b/nearby/presence/np_java_ffi/src/class/v0_data_element.rs
index 4082406..d944306 100644
--- a/nearby/presence/np_java_ffi/src/class/v0_data_element.rs
+++ b/nearby/presence/np_java_ffi/src/class/v0_data_element.rs
@@ -69,6 +69,41 @@
     }
 }
 
+static PSM_CLASS: ClassDesc =
+    ClassDesc::new("com/google/android/nearby/presence/rust/V0DataElement$Psm");
+
+/// Rust representation of `class V0DataElement.Psm`.
+#[repr(transparent)]
+pub struct Psm<Obj>(pub Obj);
+
+impl<'local> Psm<JObject<'local>> {
+    /// Create a new PSM date element with the given `psm`.
+    pub fn construct(env: &mut JNIEnv<'local>, psm: jint) -> jni::errors::Result<Self> {
+        pourover::call_constructor!(env, &PSM_CLASS, "(I)V", psm).map(Self)
+    }
+}
+
+impl<'local, Obj: AsRef<JObject<'local>>> Psm<Obj> {
+    /// Gets the value of the `int txPower` field.
+    pub fn get_psm<'env_local>(&self, env: &mut JNIEnv<'env_local>) -> jni::errors::Result<jint> {
+        static PSM_FIELD: FieldDesc = PSM_CLASS.field("psm", "I");
+        env.get_field_unchecked(self.0.as_ref(), &PSM_FIELD, ReturnType::Primitive(Primitive::Int))
+            .and_then(|ret| ret.i())
+    }
+
+    /// Get the `np_ffi_core` representation of this data element.
+    pub fn get_as_core<'env>(
+        &self,
+        env: &mut JNIEnv<'env>,
+    ) -> jni::errors::Result<common::BuildPsmResult> {
+        let psm = self.get_psm(env)?;
+        let Ok(value) = u16::try_from(psm) else {
+            return Ok(common::BuildPsmResult::OutOfRange);
+        };
+        Ok(common::Psm::build_from_unsigned_bytes(value))
+    }
+}
+
 static V0_ACTIONS_CLASS: ClassDesc =
     ClassDesc::new("com/google/android/nearby/presence/rust/V0DataElement$V0Actions");
 
diff --git a/nearby/presence/np_java_ffi/src/class/v0_payload.rs b/nearby/presence/np_java_ffi/src/class/v0_payload.rs
index ec6af1d..43a63f7 100644
--- a/nearby/presence/np_java_ffi/src/class/v0_payload.rs
+++ b/nearby/presence/np_java_ffi/src/class/v0_payload.rs
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-use crate::class::v0_data_element::{TxPower, V0Actions};
+use crate::class::v0_data_element::{Psm, TxPower, V0Actions};
 use handle_map::{Handle, HandleLike};
 use jni::{
     objects::{JClass, JObject},
@@ -85,12 +85,13 @@
 
     use np_ffi_core::{
         deserialize::v0::GetV0DEResult::{Error, Success},
-        v0::V0DataElement::{Actions, TxPower as TxPow},
+        v0::V0DataElement::{Actions, DeviceInfo, Psm as Ps, TxPower as TxPow},
     };
     let ret = match v0_payload.get_de(index) {
         Success(TxPow(tx_power)) => {
             TxPower::construct(&mut env, jint::from(tx_power.as_i8())).map(|obj| obj.0)
         }
+        Success(Ps(psm)) => Psm::construct(&mut env, jint::from(psm.as_u16())).map(|obj| obj.0),
         Success(Actions(actions)) => {
             let identity_kind = match &actions {
                 CoreV0Actions::Plaintext(_) => DeserializedV0IdentityKind::Plaintext,
@@ -99,6 +100,10 @@
 
             V0Actions::construct(&mut env, identity_kind, actions.as_u32() as jint).map(|obj| obj.0)
         }
+        Success(DeviceInfo(_)) => {
+            // TODO: implement device info data element for Java binding.
+            return JObject::null();
+        }
         Error => {
             return JObject::null();
         }
diff --git a/nearby/presence/np_java_ffi/src/class/v1_data_element.rs b/nearby/presence/np_java_ffi/src/class/v1_data_element.rs
index 1e77e68..3f83316 100644
--- a/nearby/presence/np_java_ffi/src/class/v1_data_element.rs
+++ b/nearby/presence/np_java_ffi/src/class/v1_data_element.rs
@@ -221,6 +221,10 @@
         V1DataElement::Requirements(requirements) => {
             Requirements::construct(env, requirements).map(|x| Some(x.0))
         }
+        V1DataElement::DeviceInfo(_) => {
+            // TODO: implement device info.
+            Ok(Some(JObject::null()))
+        }
     };
     match result {
         Ok(Some(result)) => result,
@@ -315,6 +319,9 @@
         V1DataElementDeserializationResult::RequirementsMalformed => {
             let _ = RequirementsMalformedException::throw_new(&mut env);
         }
+        V1DataElementDeserializationResult::DeviceInfoMalformed => {
+            // TODO: implement handling of device info malformed.
+        }
     }
     // Catch-all return value for thrown errors.
     JObject::null()
diff --git a/nearby/presence/np_java_ffi/test/com/google/android/nearby/presence/rust/V0DataElementTests.java b/nearby/presence/np_java_ffi/test/com/google/android/nearby/presence/rust/V0DataElementTests.java
new file mode 100644
index 0000000..6810d1d
--- /dev/null
+++ b/nearby/presence/np_java_ffi/test/com/google/android/nearby/presence/rust/V0DataElementTests.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2024 Google LLC
+ *
+ * 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
+ *
+ *     http://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.
+ */
+
+package com.google.android.nearby.presence.rust;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.android.nearby.presence.rust.credential.CredentialBook;
+import com.google.android.nearby.presence.rust.credential.CredentialBook.NoMetadata;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class V0DataElementTests {
+
+  /** A test which verifies TxPower to be serializable and deserializable. */
+  @Test
+  public void txPower() throws Exception {
+    NpAdv npAdv = new NpAdv();
+    try (V0AdvertisementBuilder builder = npAdv.newPublicV0Builder()) {
+      V0DataElement.TxPower txPower = new V0DataElement.TxPower(npAdv, 1);
+      builder.addDataElement(txPower);
+      byte[] adv = builder.build();
+      assertThat(adv).isNotNull();
+
+      CredentialBook<NoMetadata> book = npAdv.newEmptyCredentialBook();
+      DeserializeResult<NoMetadata> result = npAdv.deserializeAdvertisement(adv, book);
+      DeserializedV0Advertisement<NoMetadata> deserialized = result.getAsV0();
+      V0DataElement de = deserialized.getDataElement(0);
+      assertThat(de).isInstanceOf(V0DataElement.TxPower.class);
+    }
+  }
+
+  /** A test which verifies PSM to be serializable and deserializable. */
+  @Test
+  public void psm() throws Exception {
+    NpAdv npAdv = new NpAdv();
+    try (V0AdvertisementBuilder builder = npAdv.newPublicV0Builder()) {
+      V0DataElement.Psm psm = new V0DataElement.Psm(npAdv, 1);
+      builder.addDataElement(psm);
+      byte[] adv = builder.build();
+      assertThat(adv).isNotNull();
+
+      CredentialBook<NoMetadata> book = npAdv.newEmptyCredentialBook();
+      DeserializeResult<NoMetadata> result = npAdv.deserializeAdvertisement(adv, book);
+      DeserializedV0Advertisement<NoMetadata> deserialized = result.getAsV0();
+      V0DataElement de = deserialized.getDataElement(0);
+      assertThat(de).isInstanceOf(V0DataElement.Psm.class);
+      assertThat(((V0DataElement.Psm) de).getPsm()).isEqualTo(1);
+    }
+  }
+}
diff --git a/nearby/presence/rand_ext/src/lib.rs b/nearby/presence/rand_ext/src/lib.rs
index 587279d..5f42f2b 100644
--- a/nearby/presence/rand_ext/src/lib.rs
+++ b/nearby/presence/rand_ext/src/lib.rs
@@ -56,6 +56,6 @@
     let mut seed: <rand_pcg::Pcg64 as rand::SeedableRng>::Seed = Default::default();
     rand::thread_rng().fill(&mut seed);
     // print it out so if a test fails, the seed will be visible for further investigation
-    info!("seed: {:?}", seed);
+    info!("seed: {seed:?}");
     rand_pcg::Pcg64::from_seed(seed)
 }
diff --git a/nearby/presence/test_helper/src/lib.rs b/nearby/presence/test_helper/src/lib.rs
index 42097a6..1bd9d34 100644
--- a/nearby/presence/test_helper/src/lib.rs
+++ b/nearby/presence/test_helper/src/lib.rs
@@ -75,5 +75,5 @@
 /// ```
 #[cfg(feature = "std")]
 pub fn hex_bytes(data: impl AsRef<[u8]>) -> String {
-    hex::encode_upper(data).chars().tuples().map(|(a, b)| format!("0x{}{}", a, b)).join(", ")
+    hex::encode_upper(data).chars().tuples().map(|(a, b)| format!("0x{a}{b}")).join(", ")
 }
diff --git a/nearby/presence/xts_aes/tests/xts_nist_test_vectors.rs b/nearby/presence/xts_aes/tests/xts_nist_test_vectors.rs
index 9d806ad..f9ac4f6 100644
--- a/nearby/presence/xts_aes/tests/xts_nist_test_vectors.rs
+++ b/nearby/presence/xts_aes/tests/xts_nist_test_vectors.rs
@@ -199,6 +199,8 @@
 
     fn next(&mut self) -> Option<Self::Item> {
         let mut map = hash_map::HashMap::new();
+        let encrypt_decrypt_regex = regex::Regex::new("^\\[(.*)\\]$").unwrap();
+        let key_value_regex = regex::Regex::new("^(.*) = (.*)$").unwrap();
         while let Some(line) = self.delegate.next().map(|l| l.trim().to_owned()) {
             if line.starts_with('#') {
                 continue;
@@ -217,14 +219,14 @@
             }
 
             // look for `[ENCRYPT]` / `[DECRYPT]`
-            if let Some(captures) = regex::Regex::new("^\\[(.*)\\]$").unwrap().captures(&line) {
+            if let Some(captures) = encrypt_decrypt_regex.captures(&line) {
                 return Some(ParseUnit::SectionHeader(
                     captures.get(1).unwrap().as_str().to_owned(),
                 ));
             }
 
             // `key = value` in a test case chunk
-            if let Some(captures) = regex::Regex::new("^(.*) = (.*)$").unwrap().captures(&line) {
+            if let Some(captures) = key_value_regex.captures(&line) {
                 let _ = map.insert(
                     captures.get(1).unwrap().as_str().to_owned(),
                     captures.get(2).unwrap().as_str().to_owned(),
diff --git a/remoteauth/rust-toolchain.toml b/remoteauth/rust-toolchain.toml
index 0193dee..e88baf1 100644
--- a/remoteauth/rust-toolchain.toml
+++ b/remoteauth/rust-toolchain.toml
@@ -1,2 +1,2 @@
 [toolchain]
-channel = "1.83.0"
+channel = "1.88.0"