rust/bt-csis: Implement initial client and characteristic discovery

Implement the `CoordinatedSetIdentificationServiceClient` with
Read-on-Create logic as per CSIS v1.1.

- Add granular discovery helpers encapsulating find, read, and decode
- Use higher-level `ServiceCharacteristic::read` API
- Add unit tests covering success and failure modes
- Update spec references to CSIS v1.1
- Remove unused type skeletons to adhere to YAGNI rule

Bug: 534436497
Test: cargo test
Change-Id: Id438d0a1baf718a343c2bed898da2bc4198ae55b
Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/3240
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index c200dde..be959b5 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -16,6 +16,7 @@
 bt-battery = { path = "bt-battery" }
 bt-broadcast-assistant = { path = "bt-broadcast-assistant" }
 bt-common = { path = "bt-common" }
+bt-csis = { path = "bt-csis" }
 bt-gatt = { path = "bt-gatt" }
 bt-pacs = { path = "bt-pacs" }
 
diff --git a/rust/bt-csis/Cargo.toml b/rust/bt-csis/Cargo.toml
new file mode 100644
index 0000000..4eeae2e
--- /dev/null
+++ b/rust/bt-csis/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "bt-csis"
+version = "0.0.1"
+edition.workspace = true
+license.workspace = true
+
+[dependencies]
+bt-common.workspace = true
+bt-gatt.workspace = true
+thiserror.workspace = true
+
+[dev-dependencies]
+assert_matches.workspace = true
+bt-gatt = { workspace = true, features = ["test-utils"] }
+futures.workspace = true
diff --git a/rust/bt-csis/src/client.rs b/rust/bt-csis/src/client.rs
new file mode 100644
index 0000000..f897c3f
--- /dev/null
+++ b/rust/bt-csis/src/client.rs
@@ -0,0 +1,549 @@
+// Copyright 2026 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use thiserror::Error;
+
+use bt_common::packet_encoding::Decodable;
+use bt_common::Uuid;
+use bt_gatt::client::{PeerService, ServiceCharacteristic};
+use bt_gatt::types::Handle;
+
+use crate::types::*;
+
+#[derive(Debug, Error)]
+pub enum Error {
+    #[error("GATT operation error: {0}")]
+    Gatt(#[from] bt_gatt::types::Error),
+
+    #[error("Packet serialization/deserialization error: {0}")]
+    Packet(#[from] bt_common::packet_encoding::Error),
+
+    #[error("Missing required characteristic: {0}")]
+    MissingCharacteristic(Uuid),
+
+    #[error("Extra characteristic found: {0}")]
+    ExtraCharacteristic(Uuid),
+
+    #[error("Invalid characteristic value: {0}")]
+    InvalidCharacteristic(String),
+}
+
+/// A GATT client for interacting with the Coordinated Set Identification
+/// Service (CSIS). See CSIS v1.1 Section 2 & Section 5 and CSIP v1.1 Section
+/// 4.2 & Section 4.3.
+pub struct CoordinatedSetIdentificationServiceClient<T: bt_gatt::GattTypes> {
+    #[allow(dead_code)]
+    gatt_client: T::PeerService,
+    #[allow(dead_code)]
+    sirk_handle: Handle,
+    #[allow(dead_code)]
+    size_handle: Option<Handle>,
+    #[allow(dead_code)]
+    lock_handle: Option<Handle>,
+    #[allow(dead_code)]
+    rank_handle: Option<Handle>,
+
+    // Initial read values
+    sirk: SetIdentityResolvingKey,
+    size: Option<CoordinatedSetSize>,
+    rank: Option<SetMemberRank>,
+}
+
+impl<T: bt_gatt::GattTypes> CoordinatedSetIdentificationServiceClient<T> {
+    /// Discovers CSIS characteristics and reads initial characteristic values.
+    /// See CSIS v1.1 Section 5, Table 5.1 and CSIP v1.1 Section 4.3.
+    pub async fn create(gatt_client: T::PeerService) -> Result<Self, Error> {
+        let discovered = gatt_client.discover_characteristics(None).await?;
+
+        let mut sirk_chars = Vec::new();
+        let mut size_chars = Vec::new();
+        let mut rank_chars = Vec::new();
+        let mut lock_chars = Vec::new();
+
+        for ch in discovered {
+            let service_char = ServiceCharacteristic::new(&gatt_client, ch);
+            match service_char.uuid() {
+                SET_IDENTITY_RESOLVING_KEY_UUID => sirk_chars.push(service_char),
+                COORDINATED_SET_SIZE_UUID => size_chars.push(service_char),
+                SET_MEMBER_RANK_UUID => rank_chars.push(service_char),
+                SET_MEMBER_LOCK_UUID => lock_chars.push(service_char),
+                _ => {}
+            }
+        }
+
+        let (sirk_handle, sirk) = Self::read_sirk(&sirk_chars).await?;
+        let (size_handle, size) = Self::read_size(&size_chars).await?;
+        let (rank_handle, rank) = Self::read_rank(&rank_chars).await?;
+        let lock_handle = Self::get_lock_handle(&lock_chars)?;
+
+        if lock_handle.is_some() && rank_handle.is_none() {
+            // CSIS v1.1 Section 5, Table 5.1 Note C.1: Mandatory if Set Member Lock is
+            // supported
+            return Err(Error::MissingCharacteristic(SET_MEMBER_RANK_UUID));
+        }
+
+        if let (Some(r), Some(s)) = (rank, size) {
+            if r.0.get() > s.0.get() {
+                return Err(Error::InvalidCharacteristic(
+                    "SetMemberRank cannot be greater than CoordinatedSetSize".to_string(),
+                ));
+            }
+        }
+
+        Ok(Self {
+            gatt_client,
+            sirk_handle,
+            size_handle,
+            lock_handle,
+            rank_handle,
+            sirk,
+            size,
+            rank,
+        })
+    }
+
+    async fn read_sirk(
+        chars: &[ServiceCharacteristic<'_, T>],
+    ) -> Result<(Handle, SetIdentityResolvingKey), Error> {
+        if chars.is_empty() {
+            return Err(Error::MissingCharacteristic(SET_IDENTITY_RESOLVING_KEY_UUID));
+        }
+        if chars.len() > 1 {
+            return Err(Error::ExtraCharacteristic(SET_IDENTITY_RESOLVING_KEY_UUID));
+        }
+        let char_item = &chars[0];
+        let handle = *char_item.handle();
+
+        let mut buf = [0; SetIdentityResolvingKey::BYTE_SIZE];
+        let bytes_read = char_item.read(&mut buf).await?;
+        let (sirk_res, _) = SetIdentityResolvingKey::decode(&buf[..bytes_read]);
+        let sirk = sirk_res?;
+
+        Ok((handle, sirk))
+    }
+
+    async fn read_size(
+        chars: &[ServiceCharacteristic<'_, T>],
+    ) -> Result<(Option<Handle>, Option<CoordinatedSetSize>), Error> {
+        if chars.is_empty() {
+            return Ok((None, None));
+        }
+        if chars.len() > 1 {
+            return Err(Error::ExtraCharacteristic(COORDINATED_SET_SIZE_UUID));
+        }
+        let char_item = &chars[0];
+        let handle = *char_item.handle();
+
+        let mut buf = [0; CoordinatedSetSize::BYTE_SIZE];
+        let bytes_read = char_item.read(&mut buf).await?;
+        let (size_res, _) = CoordinatedSetSize::decode(&buf[..bytes_read]);
+        let size = size_res?;
+
+        Ok((Some(handle), Some(size)))
+    }
+
+    async fn read_rank(
+        chars: &[ServiceCharacteristic<'_, T>],
+    ) -> Result<(Option<Handle>, Option<SetMemberRank>), Error> {
+        if chars.is_empty() {
+            return Ok((None, None));
+        }
+        if chars.len() > 1 {
+            return Err(Error::ExtraCharacteristic(SET_MEMBER_RANK_UUID));
+        }
+        let char_item = &chars[0];
+        let handle = *char_item.handle();
+
+        let mut buf = [0; SetMemberRank::BYTE_SIZE];
+        let bytes_read = char_item.read(&mut buf).await?;
+        let (rank_res, _) = SetMemberRank::decode(&buf[..bytes_read]);
+        let rank = rank_res?;
+
+        Ok((Some(handle), Some(rank)))
+    }
+
+    fn get_lock_handle(chars: &[ServiceCharacteristic<'_, T>]) -> Result<Option<Handle>, Error> {
+        if chars.is_empty() {
+            return Ok(None);
+        }
+        if chars.len() > 1 {
+            return Err(Error::ExtraCharacteristic(SET_MEMBER_LOCK_UUID));
+        }
+        Ok(Some(*chars[0].handle()))
+    }
+
+    pub fn sirk(&self) -> SetIdentityResolvingKey {
+        self.sirk
+    }
+
+    pub fn size(&self) -> Option<CoordinatedSetSize> {
+        self.size
+    }
+
+    pub fn rank(&self) -> Option<SetMemberRank> {
+        self.rank
+    }
+
+    // TODO(b/534436497): Add lock() and unlock() methods for Exclusive
+    // Access & Set Member Locking. TODO(b/534436497): Add notification stream
+    // support for Lock and Size characteristics.
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use assert_matches::assert_matches;
+    use bt_gatt::test_utils::*;
+    use bt_gatt::types::{
+        AttributePermissions, CharacteristicProperties, CharacteristicProperty, Handle,
+    };
+    use bt_gatt::Characteristic;
+    use core::num::NonZeroU8;
+    use futures::{
+        pin_mut,
+        task::{noop_waker_ref, Context, Poll},
+        FutureExt,
+    };
+
+    const SIRK_HANDLE: Handle = Handle(1);
+    const SIZE_HANDLE: Handle = Handle(2);
+    const LOCK_HANDLE: Handle = Handle(3);
+    const RANK_HANDLE: Handle = Handle(4);
+
+    fn add_char(service: &mut FakePeerService, handle: Handle, uuid: Uuid, value: Vec<u8>) {
+        service.add_characteristic(
+            Characteristic {
+                handle,
+                uuid,
+                properties: CharacteristicProperties(vec![CharacteristicProperty::Read]),
+                permissions: AttributePermissions::default(),
+                descriptors: vec![],
+            },
+            value,
+        );
+    }
+
+    #[test]
+    fn create_success_all_chars() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        // Valid SIRK: Type 1 (Plaintext) + 16 bytes key
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+
+        add_char(&mut service, SIZE_HANDLE, COORDINATED_SET_SIZE_UUID, vec![0x02]);
+        add_char(&mut service, LOCK_HANDLE, SET_MEMBER_LOCK_UUID, vec![0x01]); // 0x01 = Unlocked per CSIS v1.1 Section 5.3, Table 5.4
+        add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x01]);
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(client) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        let client = client.unwrap();
+
+        assert_eq!(client.sirk_handle, SIRK_HANDLE);
+        assert_eq!(client.size_handle, Some(SIZE_HANDLE));
+        assert_eq!(client.lock_handle, Some(LOCK_HANDLE));
+        assert_eq!(client.rank_handle, Some(RANK_HANDLE));
+
+        assert_eq!(client.sirk().sirk_type, SirkType::Plaintext);
+        assert_eq!(client.size(), Some(CoordinatedSetSize(NonZeroU8::new(2).unwrap())));
+        assert_eq!(client.rank(), Some(SetMemberRank(NonZeroU8::new(1).unwrap())));
+    }
+
+    #[test]
+    fn create_success_only_mandatory() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(client) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        let client = client.unwrap();
+
+        assert_eq!(client.sirk_handle, SIRK_HANDLE);
+        assert_eq!(client.size_handle, None);
+        assert_eq!(client.lock_handle, None);
+        assert_eq!(client.rank_handle, None);
+    }
+
+    #[test]
+    fn create_success_rank_present_without_lock() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+        add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x01]);
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(client) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        let client = client.unwrap();
+
+        assert_eq!(client.sirk_handle, SIRK_HANDLE);
+        assert_eq!(client.size_handle, None);
+        assert_eq!(client.lock_handle, None);
+        assert_eq!(client.rank_handle, Some(RANK_HANDLE));
+        assert_eq!(client.rank().unwrap().0.get(), 1);
+    }
+
+    #[test]
+    fn create_fails_missing_sirk() {
+        let service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        assert!(result.is_err());
+        assert_matches!(result.err().unwrap(), Error::MissingCharacteristic(uuid) if uuid == SET_IDENTITY_RESOLVING_KEY_UUID);
+    }
+
+    #[test]
+    fn create_fails_duplicate_sirk() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val.clone());
+        add_char(&mut service, Handle(5), SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        assert!(result.is_err());
+        assert_matches!(result.err().unwrap(), Error::ExtraCharacteristic(uuid) if uuid == SET_IDENTITY_RESOLVING_KEY_UUID);
+    }
+
+    #[test]
+    fn create_fails_lock_present_rank_missing() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+
+        add_char(&mut service, LOCK_HANDLE, SET_MEMBER_LOCK_UUID, vec![0x01]);
+        // Missing Rank
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        assert!(result.is_err());
+        assert_matches!(result.err().unwrap(), Error::MissingCharacteristic(uuid) if uuid == SET_MEMBER_RANK_UUID);
+    }
+
+    #[test]
+    fn create_fails_invalid_size() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+
+        add_char(&mut service, SIZE_HANDLE, COORDINATED_SET_SIZE_UUID, vec![0x00]); // Invalid Size 0
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        assert!(result.is_err());
+        assert_matches!(result.err().unwrap(), Error::Packet(_));
+    }
+
+    #[test]
+    fn create_fails_invalid_rank_zero() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+        add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x00]); // Invalid Rank 0
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        assert!(result.is_err());
+        assert_matches!(result.err().unwrap(), Error::Packet(_));
+    }
+
+    #[test]
+    fn create_fails_rank_greater_than_size() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+        add_char(&mut service, SIZE_HANDLE, COORDINATED_SET_SIZE_UUID, vec![0x02]);
+        add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x03]); // Rank 3 > Size 2
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        assert!(result.is_err());
+        assert_matches!(result.err().unwrap(), Error::InvalidCharacteristic(_));
+    }
+
+    #[test]
+    fn create_fails_duplicate_size() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+
+        add_char(&mut service, SIZE_HANDLE, COORDINATED_SET_SIZE_UUID, vec![0x02]);
+        add_char(&mut service, Handle(5), COORDINATED_SET_SIZE_UUID, vec![0x03]); // Duplicate
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        assert!(result.is_err());
+        assert_matches!(result.err().unwrap(), Error::ExtraCharacteristic(uuid) if uuid == COORDINATED_SET_SIZE_UUID);
+    }
+
+    #[test]
+    fn create_fails_duplicate_lock() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+
+        add_char(&mut service, LOCK_HANDLE, SET_MEMBER_LOCK_UUID, vec![0x01]);
+        add_char(&mut service, Handle(5), SET_MEMBER_LOCK_UUID, vec![0x01]); // Duplicate
+        add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x01]); // Rank is mandatory if lock present per CSIS v1.1 Table 5.1 Note C.1
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        assert!(result.is_err());
+        assert_matches!(result.err().unwrap(), Error::ExtraCharacteristic(uuid) if uuid == SET_MEMBER_LOCK_UUID);
+    }
+
+    #[test]
+    fn create_fails_duplicate_rank() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+
+        add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![0x01]);
+        add_char(&mut service, Handle(5), SET_MEMBER_RANK_UUID, vec![0x02]); // Duplicate
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        assert!(result.is_err());
+        assert_matches!(result.err().unwrap(), Error::ExtraCharacteristic(uuid) if uuid == SET_MEMBER_RANK_UUID);
+    }
+
+    #[test]
+    fn create_fails_sirk_invalid_length() {
+        let invalid_payloads = vec![
+            vec![],        // Empty (0 bytes)
+            vec![0x01; 5], // Too short (5 bytes < 17 bytes)
+        ];
+
+        for payload in invalid_payloads {
+            let mut service = FakePeerService::new();
+            let mut noop_cx = Context::from_waker(noop_waker_ref());
+            add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, payload);
+
+            let create_fut =
+                CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+            pin_mut!(create_fut);
+            let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+                panic!("Expected create to be ready");
+            };
+            assert!(result.is_err());
+            assert_matches!(
+                result.err().unwrap(),
+                Error::Packet(bt_common::packet_encoding::Error::UnexpectedDataLength)
+            );
+        }
+    }
+
+    #[test]
+    fn create_fails_size_invalid_length() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+        add_char(&mut service, SIZE_HANDLE, COORDINATED_SET_SIZE_UUID, vec![]); // Empty (0 bytes)
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        assert!(result.is_err());
+        assert_matches!(
+            result.err().unwrap(),
+            Error::Packet(bt_common::packet_encoding::Error::UnexpectedDataLength)
+        );
+    }
+
+    #[test]
+    fn create_fails_rank_invalid_length() {
+        let mut service = FakePeerService::new();
+        let mut noop_cx = Context::from_waker(noop_waker_ref());
+
+        let mut sirk_val = vec![0x01];
+        sirk_val.extend_from_slice(&[0xAB; 16]);
+        add_char(&mut service, SIRK_HANDLE, SET_IDENTITY_RESOLVING_KEY_UUID, sirk_val);
+        add_char(&mut service, RANK_HANDLE, SET_MEMBER_RANK_UUID, vec![]); // Empty (0 bytes)
+
+        let create_fut = CoordinatedSetIdentificationServiceClient::<FakeTypes>::create(service);
+        pin_mut!(create_fut);
+        let Poll::Ready(result) = create_fut.poll_unpin(&mut noop_cx) else {
+            panic!("Expected create to be ready");
+        };
+        assert!(result.is_err());
+        assert_matches!(
+            result.err().unwrap(),
+            Error::Packet(bt_common::packet_encoding::Error::UnexpectedDataLength)
+        );
+    }
+}
diff --git a/rust/bt-csis/src/lib.rs b/rust/bt-csis/src/lib.rs
new file mode 100644
index 0000000..f5ee452
--- /dev/null
+++ b/rust/bt-csis/src/lib.rs
@@ -0,0 +1,6 @@
+// Copyright 2026 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+pub mod client;
+pub mod types;
diff --git a/rust/bt-csis/src/types.rs b/rust/bt-csis/src/types.rs
new file mode 100644
index 0000000..a96ee7d
--- /dev/null
+++ b/rust/bt-csis/src/types.rs
@@ -0,0 +1,250 @@
+// Copyright 2026 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use core::num::NonZeroU8;
+
+use bt_common::packet_encoding::{Decodable, Encodable, Error as PacketError};
+use bt_common::{decodable_enum, Uuid};
+
+/// 16-bit UUID value for the Coordinated Set Identification Service and its
+/// characteristics.
+pub const COORDINATED_SET_IDENTIFICATION_SERVICE_UUID: Uuid = Uuid::from_u16(0x1846);
+pub const SET_IDENTITY_RESOLVING_KEY_UUID: Uuid = Uuid::from_u16(0x2B84);
+pub const COORDINATED_SET_SIZE_UUID: Uuid = Uuid::from_u16(0x2B85);
+pub const SET_MEMBER_LOCK_UUID: Uuid = Uuid::from_u16(0x2B86);
+pub const SET_MEMBER_RANK_UUID: Uuid = Uuid::from_u16(0x2B87);
+
+// TODO(b/534436497): Add Coordinated Set Name characteristic (CSIS v1.1 Section
+// 5.5).
+// TODO(b/534436497): Add SetMemberLock enum and CsisApplicationError
+// enum.
+
+decodable_enum! {
+    /// The type of the Set Identity Resolving Key (SIRK).
+    /// See CSIS v1.1 Section 5.1.1, Table 5.2.
+    pub enum SirkType<u8, bt_common::packet_encoding::Error, OutOfRange> {
+        Encrypted = 0x00,
+        Plaintext = 0x01,
+    }
+}
+
+/// The Set Identity Resolving Key (SIRK) characteristic exposes the key
+/// associated with the Coordinated Set (1 octet Type + 16 octets Value).
+/// See CSIS v1.1 Section 5.1.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct SetIdentityResolvingKey {
+    pub sirk_type: SirkType,
+    pub value: [u8; 16],
+}
+
+impl SetIdentityResolvingKey {
+    pub const BYTE_SIZE: usize = 17;
+}
+
+impl Decodable for SetIdentityResolvingKey {
+    type Error = PacketError;
+
+    fn decode(buf: &[u8]) -> (core::result::Result<Self, Self::Error>, usize) {
+        if buf.len() < Self::BYTE_SIZE {
+            return (Err(PacketError::UnexpectedDataLength), buf.len());
+        }
+        let sirk_type = match SirkType::try_from(buf[0]) {
+            Ok(t) => t,
+            Err(e) => return (Err(e), Self::BYTE_SIZE),
+        };
+        let mut value = [0; 16];
+        value.copy_from_slice(&buf[1..17]);
+
+        (Ok(Self { sirk_type, value }), Self::BYTE_SIZE)
+    }
+}
+
+impl Encodable for SetIdentityResolvingKey {
+    type Error = PacketError;
+
+    fn encoded_len(&self) -> usize {
+        Self::BYTE_SIZE
+    }
+
+    fn encode(&self, buf: &mut [u8]) -> Result<(), Self::Error> {
+        if buf.len() < Self::BYTE_SIZE {
+            return Err(PacketError::BufferTooSmall);
+        }
+        buf[0] = self.sirk_type.into();
+        buf[1..17].copy_from_slice(&self.value);
+        Ok(())
+    }
+}
+
+/// The Set Member Rank characteristic exposes a numeric value that is unique
+/// within a Coordinated Set (0x01 to set size). See CSIS v1.1 Section 5.4.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct SetMemberRank(pub NonZeroU8);
+
+impl SetMemberRank {
+    pub const BYTE_SIZE: usize = 1;
+}
+
+impl Decodable for SetMemberRank {
+    type Error = PacketError;
+
+    fn decode(buf: &[u8]) -> (core::result::Result<Self, Self::Error>, usize) {
+        if buf.is_empty() {
+            return (Err(PacketError::UnexpectedDataLength), 0);
+        }
+        let val = match NonZeroU8::new(buf[0]) {
+            Some(v) => v,
+            None => {
+                return (
+                    Err(PacketError::InvalidParameter("SetMemberRank cannot be 0".to_string())),
+                    1,
+                )
+            }
+        };
+        (Ok(Self(val)), Self::BYTE_SIZE)
+    }
+}
+
+impl Encodable for SetMemberRank {
+    type Error = PacketError;
+
+    fn encoded_len(&self) -> usize {
+        Self::BYTE_SIZE
+    }
+
+    fn encode(&self, buf: &mut [u8]) -> Result<(), Self::Error> {
+        if buf.is_empty() {
+            return Err(PacketError::BufferTooSmall);
+        }
+        buf[0] = self.0.get();
+        Ok(())
+    }
+}
+
+/// The Coordinated Set Size characteristic exposes the number of devices
+/// comprising the Coordinated Set (0x01 to 0xFF). See CSIS v1.1 Section 5.2.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct CoordinatedSetSize(pub NonZeroU8);
+
+impl CoordinatedSetSize {
+    pub const BYTE_SIZE: usize = 1;
+}
+
+impl Decodable for CoordinatedSetSize {
+    type Error = PacketError;
+
+    fn decode(buf: &[u8]) -> (core::result::Result<Self, Self::Error>, usize) {
+        if buf.is_empty() {
+            return (Err(PacketError::UnexpectedDataLength), 0);
+        }
+        let val = match NonZeroU8::new(buf[0]) {
+            Some(v) => v,
+            None => {
+                return (
+                    Err(PacketError::InvalidParameter(
+                        "CoordinatedSetSize cannot be 0".to_string(),
+                    )),
+                    1,
+                );
+            }
+        };
+        (Ok(Self(val)), Self::BYTE_SIZE)
+    }
+}
+
+impl Encodable for CoordinatedSetSize {
+    type Error = PacketError;
+
+    fn encoded_len(&self) -> usize {
+        Self::BYTE_SIZE
+    }
+
+    fn encode(&self, buf: &mut [u8]) -> Result<(), Self::Error> {
+        if buf.is_empty() {
+            return Err(PacketError::BufferTooSmall);
+        }
+        buf[0] = self.0.get();
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use assert_matches::assert_matches;
+
+    #[test]
+    fn sirk_decoding() {
+        let mut buf = [0; 17];
+        buf[0] = 0x01; // Plaintext
+        buf[1..17].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
+
+        let (res, consumed) = SetIdentityResolvingKey::decode(&buf);
+        assert_eq!(consumed, 17);
+        let sirk = res.unwrap();
+        assert_eq!(sirk.sirk_type, SirkType::Plaintext);
+        assert_eq!(sirk.value, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
+    }
+
+    #[test]
+    fn sirk_encoding() {
+        let sirk = SetIdentityResolvingKey { sirk_type: SirkType::Encrypted, value: [1; 16] };
+        let mut buf = [0; 17];
+        sirk.encode(&mut buf).unwrap();
+        assert_eq!(buf[0], 0x00);
+        assert_eq!(buf[1..17], [1; 16]);
+    }
+
+    #[test]
+    fn set_member_rank_decoding() {
+        let mut buf = [0; 1];
+        buf[0] = 0x05;
+        let (res, consumed) = SetMemberRank::decode(&buf);
+        assert_eq!(consumed, 1);
+        assert_eq!(res.unwrap().0.get(), 5);
+
+        buf[0] = 0x00; // Invalid
+        let (res, _) = SetMemberRank::decode(&buf);
+        assert_matches!(res, Err(PacketError::InvalidParameter(_)));
+    }
+
+    #[test]
+    fn set_member_rank_encoding() {
+        let rank = SetMemberRank(NonZeroU8::new(5).unwrap());
+        assert_eq!(rank.encoded_len(), 1);
+
+        let mut buf = [0; 1];
+        rank.encode(&mut buf).unwrap();
+        assert_eq!(buf[0], 0x05);
+
+        let mut empty_buf = [];
+        assert_matches!(rank.encode(&mut empty_buf), Err(PacketError::BufferTooSmall));
+    }
+
+    #[test]
+    fn coordinated_set_size_decoding() {
+        let mut buf = [0; 1];
+        buf[0] = 0x02;
+        let (res, consumed) = CoordinatedSetSize::decode(&buf);
+        assert_eq!(consumed, 1);
+        assert_eq!(res.unwrap().0.get(), 2);
+
+        buf[0] = 0x00; // Invalid
+        let (res, _) = CoordinatedSetSize::decode(&buf);
+        assert_matches!(res, Err(PacketError::InvalidParameter(_)));
+    }
+
+    #[test]
+    fn coordinated_set_size_encoding() {
+        let size = CoordinatedSetSize(NonZeroU8::new(2).unwrap());
+        assert_eq!(size.encoded_len(), 1);
+
+        let mut buf = [0; 1];
+        size.encode(&mut buf).unwrap();
+        assert_eq!(buf[0], 0x02);
+
+        let mut empty_buf = [];
+        assert_matches!(size.encode(&mut empty_buf), Err(PacketError::BufferTooSmall));
+    }
+}
diff --git a/rust/bt-gatt/src/client.rs b/rust/bt-gatt/src/client.rs
index 4f2ffb1..fcaf8a0 100644
--- a/rust/bt-gatt/src/client.rs
+++ b/rust/bt-gatt/src/client.rs
@@ -143,6 +143,11 @@
 }
 
 impl<'a, T: crate::GattTypes> ServiceCharacteristic<'a, T> {
+    pub fn new(service: &'a T::PeerService, characteristic: Characteristic) -> Self {
+        let uuid = characteristic.uuid;
+        Self { service, characteristic, uuid }
+    }
+
     pub async fn find(
         service: &'a T::PeerService,
         uuid: Uuid,
diff --git a/rust/bt-gatt/src/test_utils.rs b/rust/bt-gatt/src/test_utils.rs
index a5bee21..7f274f2 100644
--- a/rust/bt-gatt/src/test_utils.rs
+++ b/rust/bt-gatt/src/test_utils.rs
@@ -114,8 +114,9 @@
         let Some((_, value)) = read_characteristics.get(handle) else {
             return ready(Err(Error::Gatt(GattError::InvalidHandle)));
         };
-        buf[..value.len()].copy_from_slice(value.as_slice());
-        ready(Ok((value.len(), false)))
+        let len = std::cmp::min(buf.len(), value.len());
+        buf[..len].copy_from_slice(&value[..len]);
+        ready(Ok((len, false)))
     }
 
     // For testing, should call `expect_characteristic_value` with the expected