, Error>
where
C: CoreClientReadOnly + OptionalSync,
@@ -169,13 +160,10 @@ where
Ok(keys)
}
-/// Applies the shared local capability filters.
+/// Returns whether a capability is a usable match for the current owner and predicate.
///
-/// A capability is considered usable locally when:
-/// - the caller-specific predicate matches,
-/// - the capability ID is not present in the trail's revoked-capability set, and
-/// - any `issued_to` restriction matches the current owner address, and
-/// - the current local time falls within the capability's validity window.
+/// A capability only matches when it satisfies the caller-provided predicate, has not been
+/// revoked, and is either unbound or explicitly issued to `owner`.
fn capability_matches(
cap: &Capability,
owner: IotaAddress,
@@ -272,8 +260,8 @@ mod tests {
let valid_roles = HashSet::from(["Writer".to_string()]);
let revoked_ids = HashSet::from([revoked_cap_id]);
- let revoked_cap = make_capability(revoked_cap_id, trail_id, "Writer", None);
- let valid_cap = make_capability(valid_cap_id, trail_id, "Writer", None);
+ let revoked_cap = make_capability(revoked_cap_id, trail_id, "Writer", None, None, None);
+ let valid_cap = make_capability(valid_cap_id, trail_id, "Writer", None, None, None);
assert!(!capability_matches(&revoked_cap, owner, 0, &revoked_ids, &|cap| cap
.matches_target_and_role(trail_id, &valid_roles)));
@@ -287,7 +275,7 @@ mod tests {
let other_owner = IotaAddress::random_for_testing_only();
let trail_id = dbg_object_id(4);
let valid_roles = HashSet::from(["Writer".to_string()]);
- let cap = make_capability(dbg_object_id(5), trail_id, "Writer", Some(other_owner));
+ let cap = make_capability(dbg_object_id(5), trail_id, "Writer", Some(other_owner), None, None);
assert!(!capability_matches(&cap, owner, 0, &HashSet::new(), &|candidate| {
candidate.matches_target_and_role(trail_id, &valid_roles)
@@ -299,8 +287,7 @@ mod tests {
let owner = IotaAddress::random_for_testing_only();
let trail_id = dbg_object_id(6);
let valid_roles = HashSet::from(["Writer".to_string()]);
- let mut cap = make_capability(dbg_object_id(7), trail_id, "Writer", None);
- cap.valid_from = Some(2_000);
+ let cap = make_capability(dbg_object_id(7), trail_id, "Writer", None, Some(2_000), None);
assert!(!capability_matches(&cap, owner, 1_999, &HashSet::new(), &|candidate| {
candidate.matches_target_and_role(trail_id, &valid_roles)
@@ -315,8 +302,7 @@ mod tests {
let owner = IotaAddress::random_for_testing_only();
let trail_id = dbg_object_id(8);
let valid_roles = HashSet::from(["Writer".to_string()]);
- let mut cap = make_capability(dbg_object_id(9), trail_id, "Writer", None);
- cap.valid_until = Some(2_000);
+ let cap = make_capability(dbg_object_id(9), trail_id, "Writer", None, None, Some(2_000));
assert!(capability_matches(&cap, owner, 2_000, &HashSet::new(), &|candidate| {
candidate.matches_target_and_role(trail_id, &valid_roles)
@@ -326,14 +312,68 @@ mod tests {
}));
}
- fn make_capability(id: ObjectID, trail_id: ObjectID, role: &str, issued_to: Option) -> Capability {
+ #[test]
+ fn capability_matches_accepts_unbound_capability_for_matching_role() {
+ let owner = IotaAddress::random_for_testing_only();
+ let trail_id = dbg_object_id(6);
+ let valid_roles = HashSet::from(["Writer".to_string()]);
+ let cap = make_capability(dbg_object_id(7), trail_id, "Writer", None, None, None);
+
+ assert!(capability_matches(&cap, owner, 0, &HashSet::new(), &|candidate| {
+ candidate.matches_target_and_role(trail_id, &valid_roles)
+ }));
+ }
+
+ #[test]
+ fn capability_matches_rejects_non_matching_role() {
+ let owner = IotaAddress::random_for_testing_only();
+ let trail_id = dbg_object_id(8);
+ let valid_roles = HashSet::from(["Writer".to_string()]);
+ let cap = make_capability(dbg_object_id(9), trail_id, "Reader", None, None, None);
+
+ assert!(!capability_matches(&cap, owner, 0, &HashSet::new(), &|candidate| {
+ candidate.matches_target_and_role(trail_id, &valid_roles)
+ }));
+ }
+
+ #[test]
+ fn capability_matches_honors_time_constraints() {
+ let owner = IotaAddress::random_for_testing_only();
+ let trail_id = dbg_object_id(10);
+ let valid_roles = HashSet::from(["Writer".to_string()]);
+ let cap = make_capability(
+ dbg_object_id(11),
+ trail_id,
+ "Writer",
+ Some(owner),
+ Some(1_700_000_000_000),
+ Some(1_700_000_005_000),
+ );
+
+ assert!(capability_matches(
+ &cap,
+ owner,
+ 1_700_000_000_000,
+ &HashSet::new(),
+ &|candidate| { candidate.matches_target_and_role(trail_id, &valid_roles) }
+ ));
+ }
+
+ fn make_capability(
+ id: ObjectID,
+ trail_id: ObjectID,
+ role: &str,
+ issued_to: Option,
+ valid_from: Option,
+ valid_until: Option,
+ ) -> Capability {
Capability {
id: UID::new(id),
target_key: trail_id,
role: role.to_string(),
issued_to,
- valid_from: None,
- valid_until: None,
+ valid_from,
+ valid_until,
}
}
}
diff --git a/audit-trail-rs/src/core/internal/linked_table.rs b/audit-trail-rs/src/core/internal/linked_table.rs
index 2ec47849..7f3f4c85 100644
--- a/audit-trail-rs/src/core/internal/linked_table.rs
+++ b/audit-trail-rs/src/core/internal/linked_table.rs
@@ -1,6 +1,8 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Helpers for reading Move `LinkedTable` nodes through dynamic fields.
+
use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions};
use iota_interaction::types::base_types::ObjectID;
use iota_interaction::types::collection_types::LinkedTableNode;
@@ -11,6 +13,10 @@ use serde::de::DeserializeOwned;
use crate::error::Error;
+/// Fetches and decodes a single linked-table node stored as a dynamic field under `table_id`.
+///
+/// The caller provides the fully encoded Move field name so this helper can stay generic over the
+/// linked-table key and value types.
pub(crate) async fn fetch_node(
client: &C,
table_id: ObjectID,
diff --git a/audit-trail-rs/src/core/internal/mod.rs b/audit-trail-rs/src/core/internal/mod.rs
index 44b8c792..c4409bcb 100644
--- a/audit-trail-rs/src/core/internal/mod.rs
+++ b/audit-trail-rs/src/core/internal/mod.rs
@@ -1,8 +1,16 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Internal helpers used to bridge public audit-trail APIs to low-level IOTA object access and
+//! programmable transaction construction.
+
+/// Capability lookup helpers for trail-scoped permission checks.
pub(crate) mod capability;
+/// Linked-table decoding helpers for traversing on-chain Move collections.
pub(crate) mod linked_table;
+/// Serde adapters for Move collection types that are exposed as standard Rust collections.
pub(crate) mod move_collections;
+/// Raw trail fetch and decode helpers.
pub(crate) mod trail;
+/// Common programmable-transaction building helpers.
pub(crate) mod tx;
diff --git a/audit-trail-rs/src/core/internal/move_collections.rs b/audit-trail-rs/src/core/internal/move_collections.rs
index 9df1cc84..ba31be21 100644
--- a/audit-trail-rs/src/core/internal/move_collections.rs
+++ b/audit-trail-rs/src/core/internal/move_collections.rs
@@ -1,6 +1,8 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Serde adapters for decoding Move collection wrappers into standard Rust collections.
+
use std::collections::{HashMap, HashSet};
use std::fmt::Debug;
use std::hash::Hash;
@@ -8,6 +10,10 @@ use std::hash::Hash;
use iota_interaction::types::collection_types::{VecMap, VecSet};
use serde::{Deserialize, Deserializer};
+/// Deserializes a Move `VecMap` into a Rust [`HashMap`].
+///
+/// This adapter is used on public domain types that expose map-like data as idiomatic Rust
+/// collections while preserving the on-chain wire format.
pub(crate) fn deserialize_vec_map<'de, D, K, V>(deserializer: D) -> Result, D::Error>
where
D: Deserializer<'de>,
@@ -22,6 +28,7 @@ where
.collect())
}
+/// Deserializes a Move `VecSet` into a Rust [`HashSet`].
pub(crate) fn deserialize_vec_set<'de, D, T>(deserializer: D) -> Result, D::Error>
where
D: Deserializer<'de>,
diff --git a/audit-trail-rs/src/core/internal/trail.rs b/audit-trail-rs/src/core/internal/trail.rs
index cd1ddee2..d90861b8 100644
--- a/audit-trail-rs/src/core/internal/trail.rs
+++ b/audit-trail-rs/src/core/internal/trail.rs
@@ -1,6 +1,8 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Helpers for fetching and decoding the shared on-chain audit-trail object.
+
use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions};
use iota_interaction::types::base_types::ObjectID;
use iota_interaction::{IotaClientTrait, OptionalSync};
@@ -9,6 +11,7 @@ use product_common::core_client::CoreClientReadOnly;
use crate::core::types::OnChainAuditTrail;
use crate::error::Error;
+/// Loads the shared audit-trail object and decodes it into [`OnChainAuditTrail`].
pub(crate) async fn get_audit_trail(trail_id: ObjectID, client: &C) -> Result
where
C: CoreClientReadOnly + OptionalSync,
diff --git a/audit-trail-rs/src/core/internal/tx.rs b/audit-trail-rs/src/core/internal/tx.rs
index 32fcb1db..8680c4d0 100644
--- a/audit-trail-rs/src/core/internal/tx.rs
+++ b/audit-trail-rs/src/core/internal/tx.rs
@@ -1,6 +1,8 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Shared transaction-building helpers used by the internal audit-trail operations.
+
use std::str::FromStr;
use iota_interaction::rpc_types::IotaObjectDataOptions;
@@ -21,6 +23,7 @@ use super::{capability, trail as trail_reader};
use crate::core::types::Permission;
use crate::error::Error;
+/// Returns the canonical immutable clock object argument.
pub(crate) fn get_clock_ref(ptb: &mut Ptb) -> Argument {
ptb.obj(ObjectArg::SharedObject {
id: IOTA_CLOCK_OBJECT_ID,
@@ -30,6 +33,8 @@ pub(crate) fn get_clock_ref(ptb: &mut Ptb) -> Argument {
.expect("network has a singleton clock instantiated")
}
+/// Serializes a pure programmable-transaction argument and annotates serialization failures with
+/// the logical argument name.
pub(crate) fn ptb_pure(ptb: &mut Ptb, name: &str, value: T) -> Result
where
T: Serialize + core::fmt::Debug,
@@ -41,6 +46,7 @@ where
})
}
+/// Wraps an optional argument into the corresponding Move `std::option::Option` value.
pub(crate) fn option_to_move(
option: Option,
tag: TypeTag,
@@ -67,6 +73,8 @@ pub(crate) fn option_to_move(
Ok(arg)
}
+/// Builds a writable trail transaction after resolving both the trail object and a matching
+/// capability for `owner`.
pub(crate) async fn build_trail_transaction(
client: &C,
trail_id: ObjectID,
@@ -89,6 +97,8 @@ where
build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await
}
+/// Builds a writable trail transaction when the caller already has the capability object
+/// reference.
pub(crate) async fn build_trail_transaction_with_cap_ref(
client: &C,
trail_id: ObjectID,
@@ -123,6 +133,7 @@ where
Ok(ptb.finish())
}
+/// Builds a read-only trail transaction that borrows the shared trail object immutably.
pub(crate) async fn build_read_only_transaction(
client: &C,
trail_id: ObjectID,
@@ -153,6 +164,10 @@ where
Ok(ptb.finish())
}
+/// Extracts the generic record payload type from the on-chain trail object type.
+///
+/// Audit-trail Move entry points are generic over the record payload type, so transaction builders
+/// need this type tag to invoke the correct specialization.
pub(crate) async fn get_type_tag(client: &C, object_id: &ObjectID) -> Result
where
C: CoreClientReadOnly + OptionalSync,
@@ -179,6 +194,7 @@ where
.map_err(|e| Error::FailedToParseTag(format!("Failed to parse tag '{type_param_str}': {e}")))
}
+/// Extracts the innermost generic type parameter from a full Move object type string.
fn parse_type(full_type: &str) -> Result {
if let (Some(start), Some(end)) = (full_type.find('<'), full_type.rfind('>')) {
Ok(full_type[start + 1..end].to_string())
@@ -189,6 +205,7 @@ fn parse_type(full_type: &str) -> Result {
}
}
+/// Fetches the current object reference for `object_id`.
pub(crate) async fn get_object_ref_by_id(
client: &impl CoreClientReadOnly,
object_id: &ObjectID,
@@ -207,6 +224,10 @@ pub(crate) async fn get_object_ref_by_id(
Ok(data.object_ref())
}
+/// Resolves a shared object argument for use in a programmable transaction.
+///
+/// This validates that the fetched object is shared and returns the appropriate mutability flag for
+/// the planned call.
pub(crate) async fn get_shared_object_arg(
client: &impl CoreClientReadOnly,
object_id: &ObjectID,
diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs
index e9f91d04..3e8cde62 100644
--- a/audit-trail-rs/src/core/locking/mod.rs
+++ b/audit-trail-rs/src/core/locking/mod.rs
@@ -1,6 +1,8 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Locking configuration APIs for audit trails.
+
use iota_interaction::types::base_types::ObjectID;
use iota_interaction::{IotaKeySignature, OptionalSync};
use product_common::core_client::CoreClient;
@@ -18,6 +20,10 @@ pub use transactions::{UpdateDeleteRecordWindow, UpdateDeleteTrailLock, UpdateLo
use self::operations::LockingOps;
+/// Locking API scoped to a specific trail.
+///
+/// This handle updates the trail's locking configuration and queries whether an individual record is currently
+/// locked against deletion.
#[derive(Debug, Clone)]
pub struct TrailLocking<'a, C> {
pub(crate) client: &'a C,
@@ -40,6 +46,10 @@ impl<'a, C> TrailLocking<'a, C> {
self
}
+ /// Replaces the full locking configuration for the trail.
+ ///
+ /// This overwrites all three locking dimensions at once: record delete window, trail delete lock, and
+ /// write lock.
pub fn update(&self, config: LockingConfig) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
@@ -54,6 +64,7 @@ impl<'a, C> TrailLocking<'a, C> {
))
}
+ /// Updates only the delete-record window.
pub fn update_delete_record_window(&self, window: LockingWindow) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
@@ -68,6 +79,7 @@ impl<'a, C> TrailLocking<'a, C> {
))
}
+ /// Updates only the delete-trail time lock.
pub fn update_delete_trail_lock(&self, lock: TimeLock) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
@@ -82,6 +94,7 @@ impl<'a, C> TrailLocking<'a, C> {
))
}
+ /// Updates only the write lock.
pub fn update_write_lock(&self, lock: TimeLock) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
@@ -96,6 +109,11 @@ impl<'a, C> TrailLocking<'a, C> {
))
}
+ /// Returns `true` when the given record is currently locked against deletion.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the lock state cannot be computed from the current on-chain state.
pub async fn is_record_locked(&self, sequence_number: u64) -> Result
where
C: AuditTrailReadOnly,
diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs
index ed726be3..c34b81af 100644
--- a/audit-trail-rs/src/core/locking/operations.rs
+++ b/audit-trail-rs/src/core/locking/operations.rs
@@ -1,6 +1,11 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Internal helpers that build locking-related programmable transactions.
+//!
+//! These helpers serialize locking values into the Move shapes used by the trail package and select the
+//! corresponding locking-update permissions.
+
use iota_interaction::OptionalSync;
use iota_interaction::types::base_types::{IotaAddress, ObjectID};
use iota_interaction::types::transaction::ProgrammableTransaction;
@@ -10,9 +15,11 @@ use crate::core::internal::tx;
use crate::core::types::{LockingConfig, LockingWindow, Permission, TimeLock};
use crate::error::Error;
+/// Internal namespace for locking transaction construction.
pub(super) struct LockingOps;
impl LockingOps {
+ /// Builds the `update_locking_config` call.
pub(super) async fn update_locking_config(
client: &C,
trail_id: ObjectID,
@@ -44,6 +51,7 @@ impl LockingOps {
.await
}
+ /// Builds the `update_delete_record_window` call.
pub(super) async fn update_delete_record_window(
client: &C,
trail_id: ObjectID,
@@ -71,6 +79,7 @@ impl LockingOps {
.await
}
+ /// Builds the `update_delete_trail_lock` call.
pub(super) async fn update_delete_trail_lock(
client: &C,
trail_id: ObjectID,
@@ -101,6 +110,7 @@ impl LockingOps {
.await
}
+ /// Builds the `update_write_lock` call.
pub(super) async fn update_write_lock(
client: &C,
trail_id: ObjectID,
@@ -131,6 +141,7 @@ impl LockingOps {
.await
}
+ /// Builds the read-only `is_record_locked` call.
pub(super) async fn is_record_locked(
client: &C,
trail_id: ObjectID,
diff --git a/audit-trail-rs/src/core/locking/transactions.rs b/audit-trail-rs/src/core/locking/transactions.rs
index a1690eb0..22e4e886 100644
--- a/audit-trail-rs/src/core/locking/transactions.rs
+++ b/audit-trail-rs/src/core/locking/transactions.rs
@@ -1,6 +1,8 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Transaction payloads for locking updates.
+
use async_trait::async_trait;
use iota_interaction::OptionalSync;
use iota_interaction::rpc_types::IotaTransactionBlockEffects;
@@ -14,6 +16,9 @@ use super::operations::LockingOps;
use crate::core::types::{LockingConfig, LockingWindow, TimeLock};
use crate::error::Error;
+/// Transaction that replaces the full locking configuration.
+///
+/// This writes the full `LockingConfig` object and therefore updates all locking dimensions in one call.
#[derive(Debug, Clone)]
pub struct UpdateLockingConfig {
trail_id: ObjectID,
@@ -24,6 +29,7 @@ pub struct UpdateLockingConfig {
}
impl UpdateLockingConfig {
+ /// Creates an `UpdateLockingConfig` transaction builder payload.
pub fn new(
trail_id: ObjectID,
owner: IotaAddress,
@@ -75,6 +81,9 @@ impl Transaction for UpdateLockingConfig {
}
}
+/// Transaction that updates the delete-record window.
+///
+/// This updates only the rule that governs when individual records may be deleted.
#[derive(Debug, Clone)]
pub struct UpdateDeleteRecordWindow {
trail_id: ObjectID,
@@ -85,6 +94,7 @@ pub struct UpdateDeleteRecordWindow {
}
impl UpdateDeleteRecordWindow {
+ /// Creates an `UpdateDeleteRecordWindow` transaction builder payload.
pub fn new(
trail_id: ObjectID,
owner: IotaAddress,
@@ -136,6 +146,9 @@ impl Transaction for UpdateDeleteRecordWindow {
}
}
+/// Transaction that updates the delete-trail lock.
+///
+/// This updates only the time lock guarding deletion of the entire trail object.
#[derive(Debug, Clone)]
pub struct UpdateDeleteTrailLock {
trail_id: ObjectID,
@@ -146,6 +159,7 @@ pub struct UpdateDeleteTrailLock {
}
impl UpdateDeleteTrailLock {
+ /// Creates an `UpdateDeleteTrailLock` transaction builder payload.
pub fn new(
trail_id: ObjectID,
owner: IotaAddress,
@@ -197,6 +211,9 @@ impl Transaction for UpdateDeleteTrailLock {
}
}
+/// Transaction that updates the write lock.
+///
+/// This updates only the time lock guarding future record writes.
#[derive(Debug, Clone)]
pub struct UpdateWriteLock {
trail_id: ObjectID,
@@ -207,6 +224,7 @@ pub struct UpdateWriteLock {
}
impl UpdateWriteLock {
+ /// Creates an `UpdateWriteLock` transaction builder payload.
pub fn new(
trail_id: ObjectID,
owner: IotaAddress,
diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs
index 36584101..53f08953 100644
--- a/audit-trail-rs/src/core/mod.rs
+++ b/audit-trail-rs/src/core/mod.rs
@@ -1,14 +1,33 @@
// Copyright 2020-2025 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
-//! Core types and builders for audit trail.
+//! Core handles, builders, transactions, and domain types for audit trails.
+//!
+//! This namespace contains the main trail-facing Rust API:
+//!
+//! - [`crate::core::access`] exposes role and capability management
+//! - [`crate::core::builder`] configures trail creation
+//! - [`crate::core::create`] contains the creation transaction types
+//! - [`crate::core::locking`] manages trail locking rules
+//! - [`crate::core::records`] reads and mutates trail records
+//! - [`crate::core::tags`] manages the trail-owned record-tag registry
+//! - [`crate::core::trail`] provides the high-level typed handle bound to a specific trail
+//! - [`crate::core::types`] contains serializable value types shared across the crate
+/// Role and capability management APIs.
pub mod access;
+/// Builder used to configure trail creation.
pub mod builder;
+/// Trail-creation transaction types.
pub mod create;
pub(crate) mod internal;
+/// Locking configuration APIs.
pub mod locking;
+/// Record read and mutation APIs.
pub mod records;
+/// Trail-scoped record-tag management APIs.
pub mod tags;
+/// High-level trail handle types.
pub mod trail;
+/// Shared domain and event types.
pub mod types;
diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs
index 02b117ce..0e32ec6f 100644
--- a/audit-trail-rs/src/core/records/mod.rs
+++ b/audit-trail-rs/src/core/records/mod.rs
@@ -1,6 +1,8 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Record read and mutation APIs for audit trails.
+
use std::collections::{BTreeMap, HashMap};
use iota_interaction::move_core_types::annotated_value::MoveValue;
@@ -29,6 +31,9 @@ use self::operations::RecordsOps;
const MAX_LIST_PAGE_LIMIT: usize = 1_000;
+/// Record API scoped to a specific trail.
+///
+/// This handle builds record-oriented transactions and loads record data from the trail's linked-table storage.
#[derive(Debug, Clone)]
pub struct TrailRecords<'a, C, D = Data> {
pub(crate) client: &'a C,
@@ -53,6 +58,11 @@ impl<'a, C, D> TrailRecords<'a, C, D> {
self
}
+ /// Loads a single record by sequence number.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the record cannot be loaded or deserialized.
pub async fn get(&self, sequence_number: u64) -> Result, Error>
where
C: AuditTrailReadOnly,
@@ -62,6 +72,10 @@ impl<'a, C, D> TrailRecords<'a, C, D> {
self.client.execute_read_only_transaction(tx).await
}
+ /// Builds a transaction that appends a record to the trail.
+ ///
+ /// Tagged writes must reference a tag already defined on the trail. They also require a capability whose
+ /// role allows both `AddRecord` and the requested tag.
pub fn add(&self, data: D, metadata: Option, tag: Option) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
@@ -79,6 +93,9 @@ impl<'a, C, D> TrailRecords<'a, C, D> {
))
}
+ /// Builds a transaction that deletes a single record.
+ ///
+ /// Deletion remains subject to record locking rules and tag-based access restrictions enforced on-chain.
pub fn delete(&self, sequence_number: u64) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
@@ -93,6 +110,9 @@ impl<'a, C, D> TrailRecords<'a, C, D> {
))
}
+ /// Builds a transaction that deletes up to `limit` records in one operation.
+ ///
+ /// Batch deletion removes records from the front of the trail and requires `DeleteAllRecords`.
pub fn delete_records_batch(&self, limit: u64) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
@@ -107,6 +127,11 @@ impl<'a, C, D> TrailRecords<'a, C, D> {
))
}
+ /// Placeholder for a future correction helper.
+ ///
+ /// # Errors
+ ///
+ /// Always returns [`Error::NotImplemented`].
pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error>
where
C: AuditTrailFull,
@@ -114,6 +139,11 @@ impl<'a, C, D> TrailRecords<'a, C, D> {
Err(Error::NotImplemented("TrailRecords::correct"))
}
+ /// Returns the number of records currently stored in the trail.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the count cannot be computed from the current on-chain state.
pub async fn record_count(&self) -> Result
where
C: AuditTrailReadOnly,
@@ -122,7 +152,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> {
self.client.execute_read_only_transaction(tx).await
}
- /// List all records into a [`HashMap`].
+ /// Lists all records into a [`HashMap`].
///
/// This traverses the full on-chain linked table and can be expensive for large trails.
/// For paginated access, use [`list_page`](Self::list_page).
@@ -135,7 +165,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> {
list_linked_table::<_, Record>(self.client, &records_table, None).await
}
- /// List all records with a hard cap to protect against expensive traversals.
+ /// Lists all records with a hard cap to protect against expensive traversals.
pub async fn list_with_limit(&self, max_entries: usize) -> Result>, Error>
where
C: AuditTrailReadOnly,
@@ -145,7 +175,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> {
list_linked_table::<_, Record>(self.client, &records_table, Some(max_entries)).await
}
- /// List one page of linked-table records starting from `cursor`.
+ /// Lists one page of linked-table records starting from `cursor`.
///
/// Pass `None` for the first page; use `next_cursor` for subsequent pages.
pub async fn list_page(&self, cursor: Option, limit: usize) -> Result, Error>
@@ -190,6 +220,7 @@ where
C: CoreClientReadOnly + OptionalSync,
V: DeserializeOwned,
{
+ // Preserve linked-table order while exposing a page as a stable Rust map keyed by sequence number.
if limit == 0 {
return Ok((BTreeMap::new(), start_key.or(table.head)));
}
@@ -233,6 +264,7 @@ where
C: CoreClientReadOnly + OptionalSync,
V: DeserializeOwned,
{
+ // Full traversal is only allowed when the caller explicitly accepts the current linked-table size.
let expected = table.size as usize;
let cap = max_entries.unwrap_or(expected);
diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs
index 50412ac1..781e6909 100644
--- a/audit-trail-rs/src/core/records/operations.rs
+++ b/audit-trail-rs/src/core/records/operations.rs
@@ -1,6 +1,11 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Internal record-operation helpers that build trail-scoped programmable transactions.
+//!
+//! These helpers enforce the Rust-side preflight checks around record tags and then encode the exact Move call
+//! arguments expected by the trail package.
+
use iota_interaction::OptionalSync;
use iota_interaction::types::base_types::{IotaAddress, ObjectID};
use iota_interaction::types::transaction::ProgrammableTransaction;
@@ -11,9 +16,14 @@ use crate::core::internal::{trail as trail_reader, tx};
use crate::core::types::{Data, Permission};
use crate::error::Error;
+/// Internal namespace for record-related transaction construction.
pub(super) struct RecordsOps;
impl RecordsOps {
+ /// Builds the `add_record` call.
+ ///
+ /// Tagged writes are prevalidated against the trail tag registry and require a capability whose role allows
+ /// both `AddRecord` and the requested tag.
pub(super) async fn add_record(
client: &C,
trail_id: ObjectID,
@@ -72,6 +82,9 @@ impl RecordsOps {
}
}
+ /// Builds the `delete_record` call.
+ ///
+ /// Authorization and locking remain enforced by the Move entry point.
pub(super) async fn delete_record(
client: &C,
trail_id: ObjectID,
@@ -98,6 +111,9 @@ impl RecordsOps {
.await
}
+ /// Builds the `delete_records_batch` call.
+ ///
+ /// Batch deletion requires `DeleteAllRecords` and deletes from the front of the trail.
pub(super) async fn delete_records_batch(
client: &C,
trail_id: ObjectID,
@@ -124,6 +140,7 @@ impl RecordsOps {
.await
}
+ /// Builds the read-only `get_record` call.
pub(super) async fn get_record(
client: &C,
trail_id: ObjectID,
@@ -139,6 +156,7 @@ impl RecordsOps {
.await
}
+ /// Builds the read-only `record_count` call.
pub(super) async fn record_count(client: &C, trail_id: ObjectID) -> Result
where
C: CoreClientReadOnly + OptionalSync,
diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs
index a09fed4e..ab4c4a63 100644
--- a/audit-trail-rs/src/core/records/transactions.rs
+++ b/audit-trail-rs/src/core/records/transactions.rs
@@ -1,6 +1,11 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Transaction payloads for record writes and deletions.
+//!
+//! These types cache the generated programmable transaction, delegate PTB construction to
+//! [`super::operations::RecordsOps`], and decode record events into typed Rust outputs.
+
use async_trait::async_trait;
use iota_interaction::OptionalSync;
use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents};
@@ -16,18 +21,29 @@ use crate::error::Error;
// ===== AddRecord =====
+/// Transaction that appends a record to a trail.
+///
+/// Tagged writes require the tag to exist in the trail registry and a capability whose role explicitly allows
+/// that tag in addition to `AddRecord`.
#[derive(Debug, Clone)]
pub struct AddRecord {
+ /// Trail object ID that will receive the record.
pub trail_id: ObjectID,
+ /// Address authorizing the write.
pub owner: IotaAddress,
+ /// Record payload to append.
pub data: Data,
+ /// Optional application-defined metadata.
pub metadata: Option,
+ /// Optional trail-owned tag to attach to the record.
pub tag: Option,
+ /// Explicit capability to use instead of auto-selecting one from the owner's wallet.
pub selected_capability_id: Option,
cached_ptb: OnceCell,
}
impl AddRecord {
+ /// Creates an `AddRecord` transaction builder payload.
pub fn new(
trail_id: ObjectID,
owner: IotaAddress,
@@ -105,16 +121,25 @@ impl Transaction for AddRecord {
// ===== DeleteRecord =====
+/// Transaction that deletes a single record.
+///
+/// This uses the single-record delete entry point, which remains subject to record-locking and tag-aware
+/// authorization checks.
#[derive(Debug, Clone)]
pub struct DeleteRecord {
+ /// Trail object ID containing the record.
pub trail_id: ObjectID,
+ /// Address authorizing the deletion.
pub owner: IotaAddress,
+ /// Sequence number of the record to delete.
pub sequence_number: u64,
+ /// Explicit capability to use instead of auto-selecting one from the owner's wallet.
pub selected_capability_id: Option,
cached_ptb: OnceCell,
}
impl DeleteRecord {
+ /// Creates a `DeleteRecord` transaction builder payload.
pub fn new(
trail_id: ObjectID,
owner: IotaAddress,
@@ -186,16 +211,25 @@ impl Transaction for DeleteRecord {
// ===== DeleteRecordsBatch =====
+/// Transaction that deletes multiple records in a batch operation.
+///
+/// The Move entry point deletes records from the front of the trail up to `limit` and reports the number of
+/// deleted records through the emitted `RecordDeleted` events.
#[derive(Debug, Clone)]
pub struct DeleteRecordsBatch {
+ /// Trail object ID containing the records.
pub trail_id: ObjectID,
+ /// Address authorizing the deletion.
pub owner: IotaAddress,
+ /// Maximum number of records to delete in this batch.
pub limit: u64,
+ /// Explicit capability to use instead of auto-selecting one from the owner's wallet.
pub selected_capability_id: Option,
cached_ptb: OnceCell,
}
impl DeleteRecordsBatch {
+ /// Creates a `DeleteRecordsBatch` transaction builder payload.
pub fn new(trail_id: ObjectID, owner: IotaAddress, limit: u64, selected_capability_id: Option) -> Self {
Self {
trail_id,
diff --git a/audit-trail-rs/src/core/tags/mod.rs b/audit-trail-rs/src/core/tags/mod.rs
index 2d51a943..c1862a79 100644
--- a/audit-trail-rs/src/core/tags/mod.rs
+++ b/audit-trail-rs/src/core/tags/mod.rs
@@ -1,6 +1,8 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Record-tag registry APIs for audit trails.
+
use iota_interaction::types::base_types::ObjectID;
use iota_interaction::{IotaKeySignature, OptionalSync};
use product_common::core_client::CoreClient;
@@ -14,6 +16,9 @@ mod transactions;
pub use transactions::{AddRecordTag, RemoveRecordTag};
+/// Tag-registry API scoped to a specific trail.
+///
+/// The registry defines the canonical set of tags that records and role-tag restrictions may reference.
#[derive(Debug, Clone)]
pub struct TrailTags<'a, C> {
pub(crate) client: &'a C,
@@ -37,6 +42,8 @@ impl<'a, C> TrailTags<'a, C> {
}
/// Adds a tag to the trail-owned record-tag registry.
+ ///
+ /// Added tags become available to future tagged record writes and role-tag restrictions.
pub fn add(&self, tag: impl Into) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
@@ -52,6 +59,8 @@ impl<'a, C> TrailTags<'a, C> {
}
/// Removes a tag from the trail-owned record-tag registry.
+ ///
+ /// Removal fails on-chain while the tag is still referenced by existing records or role-tag policies.
pub fn remove(&self, tag: impl Into) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
diff --git a/audit-trail-rs/src/core/tags/operations.rs b/audit-trail-rs/src/core/tags/operations.rs
index 36bc1980..ba86c650 100644
--- a/audit-trail-rs/src/core/tags/operations.rs
+++ b/audit-trail-rs/src/core/tags/operations.rs
@@ -1,6 +1,11 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Internal helpers that build record-tag registry transactions.
+//!
+//! These helpers encode updates to the trail-owned tag registry and select the corresponding tag-management
+//! permissions.
+
use iota_interaction::OptionalSync;
use iota_interaction::types::base_types::{IotaAddress, ObjectID};
use iota_interaction::types::transaction::ProgrammableTransaction;
@@ -10,9 +15,11 @@ use crate::core::internal::tx;
use crate::core::types::Permission;
use crate::error::Error;
+/// Internal namespace for tag-registry transaction construction.
pub(super) struct TagsOps;
impl TagsOps {
+ /// Builds the `add_record_tag` call.
pub(super) async fn add_record_tag(
client: &C,
trail_id: ObjectID,
@@ -39,6 +46,7 @@ impl TagsOps {
.await
}
+ /// Builds the `remove_record_tag` call.
pub(super) async fn remove_record_tag(
client: &C,
trail_id: ObjectID,
diff --git a/audit-trail-rs/src/core/tags/transactions.rs b/audit-trail-rs/src/core/tags/transactions.rs
index 1c4727de..171074a0 100644
--- a/audit-trail-rs/src/core/tags/transactions.rs
+++ b/audit-trail-rs/src/core/tags/transactions.rs
@@ -1,6 +1,8 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Transaction payloads for tag-registry updates.
+
use async_trait::async_trait;
use iota_interaction::OptionalSync;
use iota_interaction::rpc_types::IotaTransactionBlockEffects;
@@ -13,6 +15,9 @@ use tokio::sync::OnceCell;
use super::operations::TagsOps;
use crate::error::Error;
+/// Transaction that adds a record tag to the trail registry.
+///
+/// This extends the canonical tag registry owned by the trail.
#[derive(Debug, Clone)]
pub struct AddRecordTag {
trail_id: ObjectID,
@@ -23,6 +28,7 @@ pub struct AddRecordTag {
}
impl AddRecordTag {
+ /// Creates an `AddRecordTag` transaction builder payload.
pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String, selected_capability_id: Option) -> Self {
Self {
trail_id,
@@ -69,6 +75,9 @@ impl Transaction for AddRecordTag {
}
}
+/// Transaction that removes a record tag from the trail registry.
+///
+/// Removal only succeeds when the tag is no longer used by records or role-tag restrictions.
#[derive(Debug, Clone)]
pub struct RemoveRecordTag {
trail_id: ObjectID,
@@ -79,6 +88,7 @@ pub struct RemoveRecordTag {
}
impl RemoveRecordTag {
+ /// Creates a `RemoveRecordTag` transaction builder payload.
pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String, selected_capability_id: Option) -> Self {
Self {
trail_id,
diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs
index 593c3ac2..8e034a50 100644
--- a/audit-trail-rs/src/core/trail.rs
+++ b/audit-trail-rs/src/core/trail.rs
@@ -1,6 +1,8 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! High-level trail handles and trail-scoped transactions.
+
use iota_interaction::types::base_types::ObjectID;
use iota_interaction::types::transaction::ProgrammableTransaction;
use iota_interaction::{IotaKeySignature, OptionalSync};
@@ -22,20 +24,24 @@ mod transactions;
pub use transactions::{DeleteAuditTrail, Migrate, UpdateMetadata};
-/// Marker trait for read-only audit trail clients.
+/// Marker trait for read-only audit-trail clients.
#[doc(hidden)]
#[cfg_attr(not(feature = "send-sync"), async_trait::async_trait(?Send))]
#[cfg_attr(feature = "send-sync", async_trait::async_trait)]
pub trait AuditTrailReadOnly: CoreClientReadOnly + OptionalSync {
+ /// Executes a read-only programmable transaction and decodes the first return value.
async fn execute_read_only_transaction(&self, tx: ProgrammableTransaction)
-> Result;
}
-/// Marker trait for full (read-write) audit trail clients.
+/// Marker trait for full audit-trail clients.
#[doc(hidden)]
pub trait AuditTrailFull: AuditTrailReadOnly {}
-/// A typed handle bound to a specific audit trail and client.
+/// A typed handle bound to one trail ID and one client.
+///
+/// This is the main trail-scoped entry point. It keeps the trail identity together with the client so record,
+/// locking, access, tag, migration, and metadata operations all share one typed handle.
#[derive(Debug, Clone)]
pub struct AuditTrailHandle<'a, C> {
pub(crate) client: &'a C,
@@ -59,6 +65,8 @@ impl<'a, C> AuditTrailHandle<'a, C> {
}
/// Loads the full on-chain audit trail object.
+ ///
+ /// Each call fetches a fresh snapshot from chain state rather than reusing cached client-side data.
pub async fn get(&self) -> Result
where
C: AuditTrailReadOnly,
@@ -66,7 +74,9 @@ impl<'a, C> AuditTrailHandle<'a, C> {
trail_reader::get_audit_trail(self.trail_id, self.client).await
}
- /// Updates the trail's updatable metadata.
+ /// Updates the trail's mutable metadata field.
+ ///
+ /// Passing `None` clears the field on-chain.
pub fn update_metadata(&self, metadata: Option) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
@@ -81,7 +91,7 @@ impl<'a, C> AuditTrailHandle<'a, C> {
))
}
- /// Migrates the trail to the latest package version.
+ /// Migrates the trail to the latest package version supported by this crate.
pub fn migrate(&self) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
@@ -91,9 +101,9 @@ impl<'a, C> AuditTrailHandle<'a, C> {
TransactionBuilder::new(Migrate::new(self.trail_id, owner, self.selected_capability_id))
}
- /// Deletes the audit trail object.
+ /// Deletes the trail object.
///
- /// The trail must be empty before deletion.
+ /// Deletion requires the trail to be empty and to satisfy the trail-delete lock rules.
pub fn delete_audit_trail(&self) -> TransactionBuilder
where
C: AuditTrailFull + CoreClient,
@@ -103,18 +113,30 @@ impl<'a, C> AuditTrailHandle<'a, C> {
TransactionBuilder::new(DeleteAuditTrail::new(self.trail_id, owner, self.selected_capability_id))
}
+ /// Returns the record API scoped to this trail.
+ ///
+ /// Use this for record reads, appends, and deletions.
pub fn records(&self) -> TrailRecords<'a, C, Data> {
TrailRecords::new(self.client, self.trail_id, self.selected_capability_id)
}
+ /// Returns the locking API scoped to this trail.
+ ///
+ /// Use this for inspecting lock state and updating locking rules.
pub fn locking(&self) -> TrailLocking<'a, C> {
TrailLocking::new(self.client, self.trail_id, self.selected_capability_id)
}
+ /// Returns the access-control API scoped to this trail.
+ ///
+ /// Use this for roles, capabilities, and access-policy updates.
pub fn access(&self) -> TrailAccess<'a, C> {
TrailAccess::new(self.client, self.trail_id, self.selected_capability_id)
}
+ /// Returns the tag-registry API scoped to this trail.
+ ///
+ /// Use this for managing the canonical tag registry that record writes and role tags must reference.
pub fn tags(&self) -> TrailTags<'a, C> {
TrailTags::new(self.client, self.trail_id, self.selected_capability_id)
}
diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs
index 3b003914..e3ebfa0c 100644
--- a/audit-trail-rs/src/core/trail/operations.rs
+++ b/audit-trail-rs/src/core/trail/operations.rs
@@ -1,6 +1,11 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Internal helpers that build trail-level programmable transactions.
+//!
+//! These helpers select the required trail-level permission and encode the corresponding metadata, migration,
+//! and deletion calls.
+
use iota_interaction::OptionalSync;
use iota_interaction::types::base_types::{IotaAddress, ObjectID};
use iota_interaction::types::transaction::ProgrammableTransaction;
@@ -10,9 +15,11 @@ use crate::core::internal::tx;
use crate::core::types::Permission;
use crate::error::Error;
+/// Internal namespace for trail-level transaction construction.
pub(super) struct TrailOps;
impl TrailOps {
+ /// Builds the `migrate` call.
pub(super) async fn migrate(
client: &C,
trail_id: ObjectID,
@@ -37,6 +44,7 @@ impl TrailOps {
.await
}
+ /// Builds the `update_metadata` call.
pub(super) async fn update_metadata(
client: &C,
trail_id: ObjectID,
@@ -63,6 +71,7 @@ impl TrailOps {
.await
}
+ /// Builds the `delete_audit_trail` call.
pub(super) async fn delete_audit_trail(
client: &C,
trail_id: ObjectID,
diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs
index 47859145..8148b385 100644
--- a/audit-trail-rs/src/core/trail/transactions.rs
+++ b/audit-trail-rs/src/core/trail/transactions.rs
@@ -1,6 +1,8 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Transaction payloads for trail-level metadata, migration, and deletion operations.
+
use async_trait::async_trait;
use iota_interaction::OptionalSync;
use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents};
@@ -14,6 +16,10 @@ use super::operations::TrailOps;
use crate::core::types::{AuditTrailDeleted, Event};
use crate::error::Error;
+/// Transaction that migrates a trail to the latest package version supported by this crate.
+///
+/// This requires `Migrate` on the trail and succeeds only when the on-chain package version is older than the
+/// current supported version.
#[derive(Debug, Clone)]
pub struct Migrate {
trail_id: ObjectID,
@@ -23,6 +29,7 @@ pub struct Migrate {
}
impl Migrate {
+ /// Creates a `Migrate` transaction builder payload.
pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option) -> Self {
Self {
trail_id,
@@ -61,6 +68,9 @@ impl Transaction for Migrate {
}
}
+/// Transaction that updates mutable trail metadata.
+///
+/// Passing `None` clears the mutable metadata field.
#[derive(Debug, Clone)]
pub struct UpdateMetadata {
trail_id: ObjectID,
@@ -71,6 +81,7 @@ pub struct UpdateMetadata {
}
impl UpdateMetadata {
+ /// Creates an `UpdateMetadata` transaction builder payload.
pub fn new(
trail_id: ObjectID,
owner: IotaAddress,
@@ -122,6 +133,10 @@ impl Transaction for UpdateMetadata {
}
}
+/// Transaction that deletes an empty trail.
+///
+/// Deletion still depends on the trail-delete permission, an empty record set, and the configured trail-delete
+/// lock.
#[derive(Debug, Clone)]
pub struct DeleteAuditTrail {
trail_id: ObjectID,
@@ -131,6 +146,7 @@ pub struct DeleteAuditTrail {
}
impl DeleteAuditTrail {
+ /// Creates a `DeleteAuditTrail` transaction builder payload.
pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option) -> Self {
Self {
trail_id,
diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs
index 2cc95e50..962a3218 100644
--- a/audit-trail-rs/src/core/types/audit_trail.rs
+++ b/audit-trail-rs/src/core/types/audit_trail.rs
@@ -19,46 +19,36 @@ use crate::core::internal::move_collections::deserialize_vec_map;
use crate::core::internal::tx;
use crate::error::Error;
-/// Registry of record tags configured for an audit trail.
-///
-/// Each entry maps a tag name to its current usage count across role definitions and records.
-///
-/// `TagRegistry` maintains a combined usage count per tag that is
-/// incremented every time a record or role references the tag. A tag cannot be
-/// removed from the registry while its usage count is greater than zero.
+/// Registry of trail-owned record tags.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TagRegistry {
- /// Mapping from a human-readable tag name to how many times it is used in role definitions and records.
+ /// Mapping from tag name to usage count.
#[serde(deserialize_with = "deserialize_vec_map")]
pub tag_map: HashMap,
}
impl TagRegistry {
- /// Returns the number of tags currently registered.
+ /// Returns the number of registered tags.
pub fn len(&self) -> usize {
self.tag_map.len()
}
- /// Returns `true` if the registry contains no tags.
+ /// Returns `true` when no tags are registered.
pub fn is_empty(&self) -> bool {
self.tag_map.is_empty()
}
- /// Returns `true` if a tag with the given name exists in the registry.
- ///
- /// - `tag`: The tag name to look up.
+ /// Returns `true` when the registry contains the given tag.
pub fn contains_key(&self, tag: &str) -> bool {
self.tag_map.contains_key(tag)
}
- /// Returns the current usage count associated with a tag name.
- ///
- /// - `tag`: The tag name to look up.
+ /// Returns the usage count for a tag.
pub fn get(&self, tag: &str) -> Option<&u64> {
self.tag_map.get(tag)
}
- /// Iterates over all registered tag names and their usage counts.
+ /// Iterates over tag names and usage counts.
pub fn iter(&self) -> impl Iterator- {
self.tag_map.iter()
}
@@ -67,31 +57,27 @@ impl TagRegistry {
/// An audit trail stored on-chain.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OnChainAuditTrail {
- /// Unique object id of the audit trail.
+ /// Unique object ID of the trail.
pub id: UID,
- /// Address that originally created the trail.
+ /// Address that created the trail.
pub creator: IotaAddress,
- /// Unix timestamp in milliseconds when the trail was created.
+ /// Millisecond timestamp at which the trail was created.
pub created_at: u64,
- /// Monotonically increasing number.
- /// Can be interpreted as total number of created `Record` instances by this audit trai.
- /// Will be used as identifier for the next record added to the trail, starting at 0 for the first record.
+ /// Current record sequence number cursor.
pub sequence_number: u64,
- /// Contains the trail's records keyed by `sequence_number`.
+ /// Linked table containing the trail records.
pub records: LinkedTable,
- /// Registry of tag names tracked with their current usage counts.
- /// Tag names can be added to records to restrict record-access to users having capabilities
- /// granting access to this tag. Tag specific access can be defined by adding tags to [`Role`] definitions.
+ /// Registry of allowed record tags.
pub tags: TagRegistry,
- /// Active write/delete locking rules for this trail.
+ /// Active locking rules for the trail.
pub locking_config: LockingConfig,
- /// [`Role`] definitions and permissions configured for the trail.
+ /// Role and capability configuration for the trail.
pub roles: RoleMap,
- /// Immutable metadata set at creation time, if present.
+ /// Metadata fixed at creation time.
pub immutable_metadata: Option,
- /// Mutable metadata string that can be updated after creation, if present.
+ /// Metadata that can be updated after creation.
pub updatable_metadata: Option,
- /// On-chain schema or object version maintained by the Move package.
+ /// On-chain package version of the trail object.
pub version: u64,
}
@@ -100,22 +86,16 @@ pub struct OnChainAuditTrail {
pub struct ImmutableMetadata {
/// Human-readable trail name.
pub name: String,
- /// Optional longer description explaining the trail's purpose.
+ /// Optional human-readable description.
pub description: Option,
}
impl ImmutableMetadata {
- /// Creates immutable metadata for a new trail.
- ///
- /// - `name`: The human-readable name to store on the trail.
- /// - `description`: An optional longer description stored alongside the name.
+ /// Creates immutable metadata for a trail.
pub fn new(name: String, description: Option) -> Self {
Self { name, description }
}
- /// Returns the Move type tag for `main::ImmutableMetadata` in the given package.
- ///
- /// - `package_id`: The published audit-trail Move package id.
pub(in crate::core) fn tag(package_id: ObjectID) -> TypeTag {
TypeTag::from_str(&format!("{package_id}::main::ImmutableMetadata"))
.expect("invalid TypeTag for ImmutableMetadata")
@@ -124,9 +104,6 @@ impl ImmutableMetadata {
/// Creates a new `Argument` from the `ImmutableMetadata`.
///
/// To be used when creating a new `ImmutableMetadata` object on the ledger.
- ///
- /// - `ptb`: The programmable transaction builder the argument should be added to.
- /// - `package_id`: The published audit-trail Move package id.
pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result {
let name = tx::ptb_pure(ptb, "name", &self.name)?;
let description = tx::ptb_pure(ptb, "description", &self.description)?;
diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs
index d52fa828..988f43bf 100644
--- a/audit-trail-rs/src/core/types/event.rs
+++ b/audit-trail-rs/src/core/types/event.rs
@@ -13,102 +13,157 @@ use super::{Permission, PermissionSet, RoleTags};
/// Generic wrapper for audit trail events.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Event {
+ /// Parsed event payload.
#[serde(flatten)]
pub data: D,
}
+/// Event emitted when a trail is created.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditTrailCreated {
+ /// Newly created trail object ID.
pub trail_id: ObjectID,
+ /// Address that created the trail.
pub creator: IotaAddress,
+ /// Millisecond event timestamp.
#[serde(deserialize_with = "deserialize_number_from_string")]
pub timestamp: u64,
}
+/// Event emitted when a trail is deleted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditTrailDeleted {
+ /// Deleted trail object ID.
pub trail_id: ObjectID,
+ /// Millisecond event timestamp.
#[serde(deserialize_with = "deserialize_number_from_string")]
pub timestamp: u64,
}
+/// Event emitted when a record is added.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordAdded {
+ /// Trail object ID receiving the new record.
pub trail_id: ObjectID,
+ /// Sequence number assigned to the new record.
#[serde(deserialize_with = "deserialize_number_from_string")]
pub sequence_number: u64,
+ /// Address that added the record.
pub added_by: IotaAddress,
+ /// Millisecond event timestamp.
#[serde(deserialize_with = "deserialize_number_from_string")]
pub timestamp: u64,
}
+/// Event emitted when a record is deleted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordDeleted {
+ /// Trail object ID from which the record was deleted.
pub trail_id: ObjectID,
+ /// Sequence number of the deleted record.
#[serde(deserialize_with = "deserialize_number_from_string")]
pub sequence_number: u64,
+ /// Address that deleted the record.
pub deleted_by: IotaAddress,
+ /// Millisecond event timestamp.
#[serde(deserialize_with = "deserialize_number_from_string")]
pub timestamp: u64,
}
+/// Event emitted when a capability is issued.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityIssued {
+ /// Trail object ID protected by the capability.
pub target_key: ObjectID,
+ /// Newly created capability object ID.
pub capability_id: ObjectID,
+ /// Role granted by the capability.
pub role: String,
+ /// Address receiving the capability, if one is assigned.
pub issued_to: Option,
+ /// Millisecond timestamp at which the capability becomes valid.
#[serde(deserialize_with = "deserialize_option_number_from_string")]
pub valid_from: Option,
+ /// Millisecond timestamp at which the capability expires.
#[serde(deserialize_with = "deserialize_option_number_from_string")]
pub valid_until: Option,
}
+/// Event emitted when a capability object is destroyed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityDestroyed {
+ /// Trail object ID protected by the capability.
pub target_key: ObjectID,
+ /// Destroyed capability object ID.
pub capability_id: ObjectID,
+ /// Role granted by the capability.
pub role: String,
+ /// Address that held the capability, if any.
pub issued_to: Option,
+ /// Millisecond timestamp at which the capability became valid.
#[serde(deserialize_with = "deserialize_option_number_from_string")]
pub valid_from: Option,
+ /// Millisecond timestamp at which the capability expired.
#[serde(deserialize_with = "deserialize_option_number_from_string")]
pub valid_until: Option,
}
+/// Event emitted when a capability is revoked.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityRevoked {
+ /// Trail object ID protected by the capability.
pub target_key: ObjectID,
+ /// Revoked capability object ID.
pub capability_id: ObjectID,
+ /// Millisecond timestamp retained for denylist cleanup.
#[serde(deserialize_with = "deserialize_number_from_string")]
pub valid_until: u64,
}
+/// Event emitted when a role is created.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RoleCreated {
+ /// Trail object ID that owns the role.
pub trail_id: ObjectID,
+ /// Role name.
pub role: String,
+ /// Permissions granted by the new role.
pub permissions: PermissionSet,
+ /// Optional record-tag restrictions stored as role data.
pub data: Option,
+ /// Address that created the role.
pub created_by: IotaAddress,
+ /// Millisecond event timestamp.
pub timestamp: u64,
}
+/// Event emitted when a role is updated.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RoleUpdated {
+ /// Trail object ID that owns the role.
pub trail_id: ObjectID,
+ /// Role name.
pub role: String,
+ /// Updated permissions for the role.
pub permissions: PermissionSet,
+ /// Updated record-tag restrictions, if any.
pub data: Option,
+ /// Address that updated the role.
pub updated_by: IotaAddress,
+ /// Millisecond event timestamp.
pub timestamp: u64,
}
+/// Event emitted when a role is deleted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RoleDeleted {
+ /// Trail object ID that owned the role.
pub trail_id: ObjectID,
+ /// Role name.
pub role: String,
+ /// Address that deleted the role.
pub deleted_by: IotaAddress,
+ /// Millisecond event timestamp.
pub timestamp: u64,
}
diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs
index 1986c811..eb03205a 100644
--- a/audit-trail-rs/src/core/types/locking.rs
+++ b/audit-trail-rs/src/core/types/locking.rs
@@ -13,8 +13,11 @@ use crate::error::Error;
/// Locking configuration for the audit trail.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct LockingConfig {
+ /// Delete-window policy applied to individual records.
pub delete_record_window: LockingWindow,
+ /// Time lock that gates deletion of the entire trail.
pub delete_trail_lock: TimeLock,
+ /// Time lock that gates record writes.
pub write_lock: TimeLock,
}
@@ -47,10 +50,15 @@ impl LockingConfig {
/// Must match `tf_components::timelock::TimeLock` variant order for BCS compatibility.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TimeLock {
+ /// Unlocks at the given Unix timestamp in seconds.
UnlockAt(u32),
+ /// Unlocks at the given Unix timestamp in milliseconds.
UnlockAtMs(u64),
+ /// Remains locked until the protected object is explicitly destroyed.
UntilDestroyed,
+ /// Represents an always-locked state.
Infinite,
+ /// Disables the time lock.
#[default]
None,
}
@@ -110,12 +118,17 @@ impl TimeLock {
/// Defines a locking window (none, time based, or count based).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum LockingWindow {
+ /// No delete window is enforced.
#[default]
None,
+ /// Records may be deleted only within the given number of seconds since creation.
TimeBased {
+ /// Window size in seconds.
seconds: u64,
},
+ /// Records may be deleted only within the first `count` subsequent records.
CountBased {
+ /// Number of subsequent records after which deletion is no longer allowed.
count: u64,
},
}
diff --git a/audit-trail-rs/src/core/types/mod.rs b/audit-trail-rs/src/core/types/mod.rs
index ef9dfdcc..486299f2 100644
--- a/audit-trail-rs/src/core/types/mod.rs
+++ b/audit-trail-rs/src/core/types/mod.rs
@@ -1,13 +1,22 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
-//! Core data types for audit trail.
+//! Shared serializable domain types for audit trails.
+//!
+//! These types stay close to the on-chain data model so they can deserialize ledger state and events while also
+//! serving as the typed inputs and outputs of the Rust client API.
+/// On-chain trail metadata types.
pub mod audit_trail;
+/// Event payload types emitted by audit-trail transactions.
pub mod event;
+/// Locking configuration types.
pub mod locking;
+/// Permission and permission-set types.
pub mod permission;
+/// Record payload and pagination types.
pub mod record;
+/// Role, capability, and role-tag types.
pub mod role_map;
pub use audit_trail::*;
diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs
index a7b49ac7..57a50906 100644
--- a/audit-trail-rs/src/core/types/permission.rs
+++ b/audit-trail-rs/src/core/types/permission.rs
@@ -13,27 +13,46 @@ use serde::{Deserialize, Serialize};
use crate::error::Error;
-/// Permission enum matching the Move permission module.
+/// Audit-trail permission variants mirrored from the Move permission module.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum Permission {
+ /// Allows deleting the entire trail.
DeleteAuditTrail,
+ /// Allows deleting all records in batch form.
DeleteAllRecords,
+ /// Allows adding records.
AddRecord,
+ /// Allows deleting individual records.
DeleteRecord,
+ /// Allows creating correction records.
CorrectRecord,
+ /// Allows updating the full locking configuration.
UpdateLockingConfig,
+ /// Allows updating the delete-record window.
UpdateLockingConfigForDeleteRecord,
+ /// Allows updating the delete-trail time lock.
UpdateLockingConfigForDeleteTrail,
+ /// Allows updating the write lock.
UpdateLockingConfigForWrite,
+ /// Allows creating roles.
AddRoles,
+ /// Allows updating roles.
UpdateRoles,
+ /// Allows deleting roles.
DeleteRoles,
+ /// Allows issuing capabilities.
AddCapabilities,
+ /// Allows revoking capabilities.
RevokeCapabilities,
+ /// Allows updating mutable metadata.
UpdateMetadata,
+ /// Allows deleting mutable metadata.
DeleteMetadata,
+ /// Allows migrating the trail to a newer package version.
Migrate,
+ /// Allows adding trail-owned record tags.
AddRecordTags,
+ /// Allows deleting trail-owned record tags.
DeleteRecordTags,
}
@@ -75,9 +94,10 @@ impl Permission {
}
}
-/// Convenience wrapper for permission sets.
+/// Convenience wrapper around a set of [`Permission`] values.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PermissionSet {
+ /// Permissions granted by this set.
pub permissions: HashSet,
}
@@ -92,6 +112,7 @@ impl PermissionSet {
Ok(ptb.command(Command::MakeMoveVec(Some(permission_type.into()), permission_args)))
}
+ /// Returns the recommended role-administration permissions.
pub fn admin_permissions() -> Self {
Self {
permissions: HashSet::from([
@@ -106,6 +127,7 @@ impl PermissionSet {
}
}
+ /// Returns the permissions needed to administer records.
pub fn record_admin_permissions() -> Self {
Self {
permissions: HashSet::from([
@@ -116,6 +138,7 @@ impl PermissionSet {
}
}
+ /// Returns the permissions needed to administer locking rules.
pub fn locking_admin_permissions() -> Self {
Self {
permissions: HashSet::from([
@@ -127,24 +150,28 @@ impl PermissionSet {
}
}
+ /// Returns the permissions needed to administer roles.
pub fn role_admin_permissions() -> Self {
Self {
permissions: HashSet::from([Permission::AddRoles, Permission::UpdateRoles, Permission::DeleteRoles]),
}
}
+ /// Returns the permissions needed to administer record tags.
pub fn tag_admin_permissions() -> Self {
Self {
permissions: HashSet::from([Permission::AddRecordTags, Permission::DeleteRecordTags]),
}
}
+ /// Returns the permissions needed to issue and revoke capabilities.
pub fn cap_admin_permissions() -> Self {
Self {
permissions: HashSet::from_iter(vec![Permission::AddCapabilities, Permission::RevokeCapabilities]),
}
}
+ /// Returns the permissions needed to administer mutable metadata.
pub fn metadata_admin_permissions() -> Self {
Self {
permissions: HashSet::from_iter(vec![Permission::UpdateMetadata, Permission::DeleteMetadata]),
diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs
index d20f743d..e18e1ff2 100644
--- a/audit-trail-rs/src/core/types/record.rs
+++ b/audit-trail-rs/src/core/types/record.rs
@@ -17,32 +17,62 @@ use crate::error::Error;
/// Page of records loaded through linked-table traversal.
#[derive(Debug, Clone)]
pub struct PaginatedRecord {
+ /// Records included in the current page, keyed by sequence number.
pub records: BTreeMap>,
+ /// Cursor to pass to the next [`TrailRecords::list_page`](crate::core::records::TrailRecords::list_page) call.
pub next_cursor: Option,
+ /// Indicates whether another page may be available.
pub has_next_page: bool,
}
/// A single record in the audit trail.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
+ /// Record payload stored on-chain.
pub data: D,
+ /// Optional application-defined metadata.
pub metadata: Option,
+ /// Optional trail-owned tag attached to the record.
pub tag: Option,
+ /// Monotonic record sequence number inside the trail.
pub sequence_number: u64,
+ /// Address that added the record.
pub added_by: IotaAddress,
+ /// Millisecond timestamp at which the record was added.
pub added_at: u64,
+ /// Correction relationships for this record.
pub correction: RecordCorrection,
}
/// Input used when creating a trail with an initial record.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InitialRecord {
+ /// Initial payload to store in the trail.
pub data: D,
+ /// Optional application-defined metadata.
pub metadata: Option,
+ /// Optional initial tag from the trail-owned registry.
pub tag: Option,
}
impl InitialRecord {
+ /// Creates a new initial record.
+ ///
+ /// # Examples
+ ///
+ /// ```rust
+ /// use audit_trail::core::types::{Data, InitialRecord};
+ ///
+ /// let record = InitialRecord::new(
+ /// Data::text("hello"),
+ /// Some("seed".to_string()),
+ /// Some("inbox".to_string()),
+ /// );
+ ///
+ /// assert_eq!(record.data, Data::text("hello"));
+ /// assert_eq!(record.metadata.as_deref(), Some("seed"));
+ /// assert_eq!(record.tag.as_deref(), Some("inbox"));
+ /// ```
pub fn new(data: impl Into, metadata: Option, tag: Option) -> Self {
Self {
data: data.into(),
@@ -78,11 +108,14 @@ impl InitialRecord {
/// Bidirectional correction tracking for audit records.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordCorrection {
+ /// Sequence numbers that this record supersedes.
pub replaces: HashSet,
+ /// Sequence number of the record that supersedes this one, if any.
pub is_replaced_by: Option,
}
impl RecordCorrection {
+ /// Creates a correction value that replaces the given sequence numbers.
pub fn with_replaces(replaces: HashSet) -> Self {
Self {
replaces,
@@ -90,10 +123,24 @@ impl RecordCorrection {
}
}
+ /// Returns `true` when this record supersedes at least one earlier record.
+ ///
+ /// # Examples
+ ///
+ /// ```rust
+ /// use std::collections::HashSet;
+ ///
+ /// use audit_trail::core::types::RecordCorrection;
+ ///
+ /// let correction = RecordCorrection::with_replaces(HashSet::from([1, 2]));
+ ///
+ /// assert!(correction.is_correction());
+ /// ```
pub fn is_correction(&self) -> bool {
!self.replaces.is_empty()
}
+ /// Returns `true` when this record has itself been replaced by a later record.
pub fn is_replaced(&self) -> bool {
self.is_replaced_by.is_some()
}
@@ -102,7 +149,9 @@ impl RecordCorrection {
/// Supported record data types.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Data {
+ /// Arbitrary binary payload.
Bytes(Vec),
+ /// UTF-8 text payload.
Text(String),
}
@@ -153,11 +202,27 @@ impl Data {
}
/// Creates a new `Data` from bytes.
+ ///
+ /// # Examples
+ ///
+ /// ```rust
+ /// use audit_trail::core::types::Data;
+ ///
+ /// assert_eq!(Data::bytes([1_u8, 2, 3]), Data::Bytes(vec![1, 2, 3]));
+ /// ```
pub fn bytes(data: impl Into>) -> Self {
Self::Bytes(data.into())
}
/// Creates a new `Data` from text.
+ ///
+ /// # Examples
+ ///
+ /// ```rust
+ /// use audit_trail::core::types::Data;
+ ///
+ /// assert_eq!(Data::text("hello"), Data::Text("hello".to_string()));
+ /// ```
pub fn text(data: impl Into) -> Self {
Self::Text(data.into())
}
diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs
index fb3d9cc3..29e45c41 100644
--- a/audit-trail-rs/src/core/types/role_map.rs
+++ b/audit-trail-rs/src/core/types/role_map.rs
@@ -19,227 +19,88 @@ use crate::core::internal::move_collections::{deserialize_vec_map, deserialize_v
use crate::core::internal::tx;
use crate::error::Error;
-/// The role and capability registry attached to an audit trail.
+/// Role and capability configuration stored on a trail.
///
-/// A [`RoleMap`] stores every named role defined on the trail, tracks which
-/// capabilities have been revoked, and records the administrative permission
-/// requirements for role and capability management.
-///
-/// ## Roles and capabilities
-///
-/// Each entry in [`roles`](RoleMap::roles) maps a role name to a [`Role`] that
-/// holds a set of [`Permission`]s and [`RoleTags`].
-/// Each [`Capability`] is associated with exactly one [`Role`] and belongs to a specific [`AuditTrail`]
-/// instance which is identified by the [`target_key`](RoleMap::target_key).
-///
-/// ## What are Roles
-///
-/// A role is a named set of [`Permission`]s, optionally paired with a [`RoleTags`] allowlist.
-///
-/// Roles are identified by a unique string name within a trail (e.g., `"RecordAdmin"`,
-/// `"Auditor"`, `"LegalReviewer"`). The same role definition can back many independent
-/// [`Capability`] objects — to be owned and used by users or system components that should share the same
-/// access level. A capability holder may exercise only the permissions of the role it was
-/// issued for.
-///
-/// ## Initial admin role and capability
-///
-/// When a trail is created the Move runtime mints an *initial admin*
-/// capability and transfers it to the creator (or the address supplied via
-/// `with_admin`).
-///
-/// The *initial admin* role name is indicated by [`initial_admin_role_name`].
-/// The role grants permissions specified by [`role_admin_permissions`] and [`capability_admin_permissions`],
-/// which are required to manage additional roles and capabilities.
-///
-/// ## Create, Delete and Update Roles
-///
-/// All three operations are gated by the permissions stored in
-/// [`role_admin_permissions`](RoleMap::role_admin_permissions):
-///
-/// | Operation | Required permission | Additional constraints |
-/// |-----------|--------------------------------------|-----------------------------------------------------------------------------------------------------------------|
-/// | Create | `role_admin_permissions.add` | Any [`RoleTags`] specified must be registered in the trail's tag registry. |
-/// | Delete | `role_admin_permissions.delete` | The initial admin role (see [`initial_admin_role_name`](RoleMap::initial_admin_role_name)) cannot be deleted. |
-/// | Update | `role_admin_permissions.update` | Updating the initial admin role requires the new permission set to still include all configured admin permissions.|
-///
-/// The caller supplies a [`Capability`] that is validated before the operation proceeds.
-/// An `ECapabilityPermissionDenied` error is returned if the capability's role does not
-/// carry the required permission.
-///
-/// ## Issue, Revoke, and Destroy Capabilities
-///
-/// **Issuing** a capability requires the `capability_admin_permissions.add` permission.
-/// [`CapabilityIssueOptions`] allow restricting a newly minted capability further:
-/// - `issued_to` — binds the capability to a specific wallet address; the Move runtime rejects use by any other sender.
-/// - `valid_from_ms` / `valid_until_ms` — a Unix-millisecond validity window; use outside this range is rejected.
-///
-/// **Revoking** a capability requires the `capability_admin_permissions.revoke` permission.
-/// Revocation adds the capability's ID to the [`revoked_capabilities`](RoleMap::revoked_capabilities)
-/// denylist; the object itself continues to exist on-chain but is refused by
-/// `assert_capability_valid`. The caller must provide:
-/// - the capability's object ID, and
-/// - optionally its `valid_until` value, which allows the denylist entry to be cleaned up automatically once it expires
-/// via [`AuditTrailHandle::access().cleanup_revoked_capabilities`].
-///
-/// Because the `RoleMap` uses a denylist (not an allowlist), it does **not** track all
-/// issued capabilities on-chain. Callers are responsible for maintaining an off-chain
-/// record of issued capability IDs and their validity constraints so that the correct ID
-/// can be supplied at revocation time.
-///
-/// **Destroying** a capability permanently removes it from the chain. Any holder may
-/// destroy their own capability without needing any admin permission — this is intentional
-/// so that users can always clean up capabilities they no longer need. Destroying a
-/// revoked capability also removes it from the denylist.
-///
-/// ## Managing the initial admin role and its capabilities
-///
-/// The initial admin role is the only role that exists when a trail is first created.
-/// It carries all permissions required to manage roles and capabilities
-/// (i.e. everything in [`role_admin_permissions`](RoleMap::role_admin_permissions) and
-/// [`capability_admin_permissions`](RoleMap::capability_admin_permissions)).
-///
-/// Two invariants protect it from accidental lock-out:
-/// - The initial admin **role** can never be deleted.
-/// - Updating its permissions is only permitted if the new permission set still includes all configured role and
-/// capability admin permissions.
-///
-/// Initial admin **capabilities** are tracked separately in
-/// [`initial_admin_cap_ids`](RoleMap::initial_admin_cap_ids) and must be managed through
-/// dedicated entry-points:
-/// - `revoke_initial_admin_capability` — adds the cap to the denylist.
-/// - `destroy_initial_admin_capability` — permanently removes the cap from the chain.
-///
-/// Attempting to use the generic `revoke_capability` or `destroy_capability` on an initial
-/// admin capability returns `EInitialAdminCapabilityMustBeExplicitlyDestroyed`.
-///
-/// ## Using Tags
-/// Tags are string labels managed by the audit trail using a [`TagRegistry`](super::audit_trail::TagRegistry).
-/// The registry acts as a controlled vocabulary: a tag must be registered on the
-/// trail before it can be attached to a record or referenced by a role.
-///
-/// Each record may carry at most one immutable tag. Tagged Records can only be accessed
-/// by users having Capabilities with Roles that allow the tag in their RoleTags.
-/// This allows for flexible access control policies based on record tags,
-/// i.e. to allow access to specific records only for users in specific departments.
-///
-/// Each role may optionally include a [`RoleTags`] allowlist that grants the holders of that
-/// role's capability access to records tagged with that specific tag.
+/// This mirrors the access-control state maintained by the Move package, including the reserved initial-admin
+/// role, the revoked-capability denylist, and the role data used for tag-aware authorization.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoleMap {
- /// The object ID of the audit trail this role map belongs to.
+ /// Trail object ID that this role map protects.
pub target_key: ObjectID,
- /// All named roles defined on the trail, keyed by role name.
+ /// Role definitions keyed by role name.
#[serde(deserialize_with = "deserialize_vec_map")]
pub roles: HashMap,
- /// Name of the built-in admin role created automatically at trail creation
- /// (typically `"Admin"`).
+ /// Reserved role name used for initial-admin capabilities.
pub initial_admin_role_name: String,
- /// Set of capability IDs that have been revoked and must no longer be
- /// accepted by the Move runtime.
+ /// Denylist of revoked capability IDs.
pub revoked_capabilities: LinkedTable,
- /// Object IDs of the initial admin capabilities minted at trail creation.
- /// These require dedicated revoke/destroy entry-points.
+ /// Capability IDs currently recognized as initial-admin capabilities.
#[serde(deserialize_with = "deserialize_vec_set")]
pub initial_admin_cap_ids: HashSet,
- /// Permissions required to add, update, and delete roles on this trail.
+ /// Permissions required to administer roles.
pub role_admin_permissions: RoleAdminPermissions,
- /// Permissions required to issue and revoke capabilities on this trail.
+ /// Permissions required to administer capabilities.
pub capability_admin_permissions: CapabilityAdminPermissions,
}
-/// A single role definition within a [`RoleMap`].
-///
-/// Each role combines a permission set that governs what operations holders may
-/// perform, and optional [`RoleTags`] data that restricts which tagged records
-/// those holders may interact with.
+/// Role definition stored in the trail role map.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Role {
- /// The set of [`Permission`]s granted to any [`Capability`] issued for this role.
+ /// Permissions granted by the role.
#[serde(deserialize_with = "deserialize_vec_set")]
pub permissions: HashSet,
- /// Optional tag allowlist. When present, a capability holder for this role may only
- /// add or access records whose tag is contained in this set. When `None`, the role
- /// does not impose any tag-based restriction (but untagged-record permissions still
- /// apply).
+ /// Optional role-scoped record-tag restrictions.
pub data: Option,
}
-/// Defines the permissions required to administer roles in this [`RoleMap`].
-///
-/// When a capability holder attempts to create, delete, or update a role, the
-/// `RoleMap` checks that the holder's role includes the corresponding permission
-/// listed here.
+/// Permissions required to administer roles in the trail's access-control state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoleAdminPermissions {
- /// The [`Permission`] a capability must carry to create a new role
- /// (typically [`Permission::AddRoles`]).
+ /// Permission required to create roles.
pub add: Permission,
- /// The [`Permission`] a capability must carry to delete an existing role
- /// (typically [`Permission::DeleteRoles`]).
+ /// Permission required to delete roles.
pub delete: Permission,
- /// The [`Permission`] a capability must carry to update the permissions or
- /// tags of an existing role (typically [`Permission::UpdateRoles`]).
+ /// Permission required to update roles.
pub update: Permission,
}
-/// Defines the permissions required to administer capabilities in this [`RoleMap`].
-///
-/// When a capability holder attempts to issue or revoke a capability, the
-/// `RoleMap` checks that the holder's role includes the corresponding permission
-/// listed here.
+/// Permissions required to administer capabilities in the trail's access-control state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityAdminPermissions {
- /// The [`Permission`] a capability must carry to issue (mint) a new capability
- /// (typically [`Permission::AddCapabilities`]).
+ /// Permission required to issue capabilities.
pub add: Permission,
- /// The [`Permission`] a capability must carry to revoke an existing capability
- /// or to clean up the revoked-capabilities denylist
- /// (typically [`Permission::RevokeCapabilities`]).
+ /// Permission required to revoke capabilities.
pub revoke: Permission,
}
-/// Options for constraining a newly issued [`Capability`].
+/// Capability issuance options used by the role-based API.
///
-/// All fields default to `None` (no restriction). Use [`Default::default()`]
-/// to issue an unrestricted capability, or populate individual fields to add
-/// constraints.
+/// These fields only configure restrictions on the issued capability object. Matching against the current
+/// caller and timestamp happens when the capability is later used.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityIssueOptions {
- /// If set, only the specified address may present the capability. The Move
- /// runtime rejects any transaction from a different sender with
- /// `ECapabilityIssuedToMismatch`.
+ /// Address that should own the capability, if any.
pub issued_to: Option,
- /// If set, the capability is not valid before this Unix timestamp
- /// (milliseconds since epoch). Transactions submitted before this time
- /// are rejected with `ECapabilityTimeConstraintsNotMet`.
+ /// Millisecond timestamp at which the capability becomes valid.
pub valid_from_ms: Option,
- /// If set, the capability expires after this Unix timestamp (milliseconds
- /// since epoch). Transactions submitted after this time are rejected with
- /// `ECapabilityTimeConstraintsNotMet`.
+ /// Millisecond timestamp at which the capability expires.
pub valid_until_ms: Option,
}
-/// An allowlist of record tag names that may be attached to a [`Role`].
+/// Allowlisted record tags stored as role data.
///
-/// When a role carries a `RoleTags` value, capability holders for that role may
-/// only add or interact with records whose tag is contained in [`tags`](RoleTags::tags).
-/// This maps to the Move `record_tags::RoleTags` type.
+/// The Rust name stays `RecordTags` for API continuity, but it maps to Move `record_tags::RoleTags`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RoleTags {
- /// The set of tag names this role is allowed to use. Every tag listed here
- /// must be registered in the trail's tag registry before the role is created
- /// or updated.
+ /// Allowlisted record tags for the role.
#[serde(deserialize_with = "deserialize_vec_set")]
pub tags: HashSet,
}
impl RoleTags {
- /// Creates a new [`RoleTags`] from any iterator of string-like items.
- ///
- /// # Arguments
+ /// Creates role-tag restrictions from an iterator of tag names.
///
- /// * `tags` — an iterator of tag names (e.g., `["finance", "legal"]`).
+ /// The set is deduplicated, and PTB encoding later sorts the tags for deterministic serialization.
pub fn new(tags: I) -> Self
where
I: IntoIterator
- ,
@@ -250,11 +111,7 @@ impl RoleTags {
}
}
- /// Returns `true` if `tag` is present in this allowlist.
- ///
- /// # Arguments
- ///
- /// * `tag` — the record tag name to check.
+ /// Returns `true` when the given tag is allowed for the role.
pub fn allows(&self, tag: &str) -> bool {
self.tags.contains(tag)
}
@@ -278,49 +135,33 @@ impl RoleTags {
}
}
-/// An on-chain capability object deserialized from the Move `capability::Capability` type.
+/// Capability data returned by the Move capability module.
///
-/// A capability grants its holder the permissions of the [`Role`] identified by
-/// [`role`](Capability::role) on the trail identified by
-/// [`target_key`](Capability::target_key). The `RoleMap` validates all fields
-/// of the capability before allowing any operation to proceed.
+/// A capability grants exactly one role against exactly one trail and may additionally restrict who may use it
+/// and during which time window it is valid.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Capability {
- /// The unique on-chain object ID of this capability.
+ /// Capability object ID.
pub id: UID,
- /// The object ID of the audit trail this capability is valid for.
- /// Must match the [`RoleMap::target_key`] of the trail being accessed.
+ /// Trail object ID protected by the capability.
pub target_key: ObjectID,
- /// The name of the role this capability was issued for (e.g., `"Admin"`,
- /// `"RecordAdmin"`). Determines the set of [`Permission`]s the holder may
- /// exercise.
+ /// Role granted by the capability.
pub role: String,
- /// Optional address binding. When set, only the specified address may
- /// present this capability; any other sender is rejected.
+ /// Capability holder, if the capability is assigned to an address.
pub issued_to: Option,
- /// Optional start of the validity window (Unix milliseconds). The
- /// capability is rejected before this timestamp.
+ /// Millisecond timestamp at which the capability becomes valid.
#[serde(deserialize_with = "deserialize_option_number_from_string")]
pub valid_from: Option,
- /// Optional end of the validity window (Unix milliseconds). The capability
- /// is rejected after this timestamp.
+ /// Millisecond timestamp at which the capability expires.
#[serde(deserialize_with = "deserialize_option_number_from_string")]
pub valid_until: Option,
}
impl Capability {
- /// Returns the Move `TypeTag` for `capability::Capability` in the given package.
pub(crate) fn type_tag(package_id: ObjectID) -> TypeTag {
TypeTag::from_str(format!("{package_id}::capability::Capability").as_str()).expect("failed to create type tag")
}
- /// Returns `true` if this capability targets the given trail and its role is
- /// contained in `valid_roles`.
- ///
- /// # Arguments
- ///
- /// * `trail_id` — the object ID of the trail to match against.
- /// * `valid_roles` — the set of role names considered acceptable.
pub(crate) fn matches_target_and_role(&self, trail_id: ObjectID, valid_roles: &HashSet) -> bool {
self.target_key == trail_id && valid_roles.contains(&self.role)
}
@@ -331,3 +172,53 @@ impl MoveType for Capability {
Self::type_tag(package)
}
}
+
+#[cfg(test)]
+mod tests {
+ use iota_interaction::types::base_types::{IotaAddress, dbg_object_id};
+ use iota_interaction::types::id::UID;
+ use serde_json::json;
+
+ use super::Capability;
+
+ #[test]
+ fn capability_deserializes_string_encoded_time_constraints() {
+ let issued_to = IotaAddress::random_for_testing_only();
+ let capability = Capability {
+ id: UID::new(dbg_object_id(1)),
+ target_key: dbg_object_id(2),
+ role: "Writer".to_string(),
+ issued_to: Some(issued_to),
+ valid_from: None,
+ valid_until: None,
+ };
+
+ let mut value = serde_json::to_value(capability).expect("capability serializes");
+ value["valid_from"] = json!("1700000000000");
+ value["valid_until"] = json!("1700000005000");
+
+ let decoded: Capability = serde_json::from_value(value).expect("capability deserializes");
+
+ assert_eq!(decoded.valid_from, Some(1_700_000_000_000));
+ assert_eq!(decoded.valid_until, Some(1_700_000_005_000));
+ assert_eq!(decoded.issued_to, Some(issued_to));
+ }
+
+ #[test]
+ fn capability_deserializes_absent_time_constraints() {
+ let capability = Capability {
+ id: UID::new(dbg_object_id(4)),
+ target_key: dbg_object_id(5),
+ role: "Writer".to_string(),
+ issued_to: None,
+ valid_from: None,
+ valid_until: None,
+ };
+
+ let value = serde_json::to_value(capability).expect("capability serializes");
+ let decoded: Capability = serde_json::from_value(value).expect("capability deserializes");
+
+ assert_eq!(decoded.valid_from, None);
+ assert_eq!(decoded.valid_until, None);
+ }
+}
diff --git a/audit-trail-rs/src/error.rs b/audit-trail-rs/src/error.rs
index 79f75834..af81958a 100644
--- a/audit-trail-rs/src/error.rs
+++ b/audit-trail-rs/src/error.rs
@@ -1,34 +1,36 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+//! Error types returned by the audit-trail public API.
+
use crate::iota_interaction_adapter::AdapterError;
-/// Errors that can occur when managing Audit Trails
+/// Errors that can occur when reading or mutating audit trails.
#[derive(Debug, thiserror::Error, strum::IntoStaticStr)]
#[non_exhaustive]
pub enum Error {
- /// Caused by invalid keys.
+ /// Returned when a signer key or public key cannot be derived or validated.
#[error("invalid key: {0}")]
InvalidKey(String),
- /// Config is invalid.
+ /// Returned when client configuration or package-ID configuration is invalid.
#[error("invalid config: {0}")]
InvalidConfig(String),
- /// An error caused by either a connection issue or an invalid RPC call.
+ /// Returned when an RPC request fails.
#[error("RPC error: {0}")]
RpcError(String),
- /// The provided IOTA Client returned an error
+ /// Error returned by the underlying IOTA client adapter.
#[error("IOTA client error: {0}")]
IotaClient(#[from] AdapterError),
- /// Generic error
+ /// Generic catch-all error for crate-specific failures that do not fit a narrower variant.
#[error("{0}")]
GenericError(String),
/// Placeholder for unimplemented API surface.
#[error("not implemented: {0}")]
NotImplemented(&'static str),
- /// Failed to parse tag
+ /// Returned when a Move tag cannot be parsed.
#[error("Failed to parse tag: {0}")]
FailedToParseTag(String),
- /// Invalid argument
+ /// Returned when an argument is semantically invalid.
#[error("Invalid argument: {0}")]
InvalidArgument(String),
/// The response from the IOTA node API was not in the expected format.
@@ -37,7 +39,7 @@ pub enum Error {
/// Failed to deserialize data using BCS.
#[error("BCS deserialization error: {0}")]
DeserializationError(#[from] bcs::Error),
- /// The response from the IOTA node API was not in the expected format.
+ /// The transaction response from the IOTA node API was not in the expected format.
#[error("unexpected transaction response: {0}")]
TransactionUnexpectedResponse(String),
}
diff --git a/audit-trail-rs/src/iota_interaction_adapter.rs b/audit-trail-rs/src/iota_interaction_adapter.rs
index ec0d9f0a..c2db171b 100644
--- a/audit-trail-rs/src/iota_interaction_adapter.rs
+++ b/audit-trail-rs/src/iota_interaction_adapter.rs
@@ -1,9 +1,10 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
-// The following platform compile switch provides all the
-// ...Adapter types from iota_interaction_rust or iota_interaction_ts
-// like IotaClientAdapter, TransactionBuilderAdapter ... and so on
+//! Platform-dependent adapter re-exports for the underlying IOTA interaction layer.
+//!
+//! This keeps the rest of the crate generic over native and wasm targets by exposing the same
+//! adapter names from either `iota_interaction_rust` or `iota_interaction_ts`.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use iota_interaction_rust::*;
diff --git a/audit-trail-rs/src/lib.rs b/audit-trail-rs/src/lib.rs
index 2c8f8644..82f6f73e 100644
--- a/audit-trail-rs/src/lib.rs
+++ b/audit-trail-rs/src/lib.rs
@@ -1,13 +1,21 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
+#![doc = include_str!("../README.md")]
+#![warn(missing_docs, rustdoc::all)]
+
+/// Client wrappers for read-only and signing access to audit trails.
pub mod client;
+/// Core handles, builders, transactions, and domain types.
pub mod core;
+/// Error types returned by the public API.
pub mod error;
pub(crate) mod iota_interaction_adapter;
pub(crate) mod package;
+/// A signing audit-trail client that can build write transactions.
pub use client::full_client::AuditTrailClient;
+/// Read-only client types and package override configuration.
pub use client::read_only::{AuditTrailClientReadOnly, PackageOverrides};
/// HTTP utilities to implement the trait [HttpClient](product_common::http_client::HttpClient).
#[cfg(feature = "gas-station")]
diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs
index 4818f56b..0d443bb7 100644
--- a/audit-trail-rs/src/package.rs
+++ b/audit-trail-rs/src/package.rs
@@ -85,7 +85,7 @@ pub(crate) async fn resolve_package_ids(
let chain_id = network.as_ref().to_string();
let package_registry = audit_trail_package_registry().await;
let audit_trail_package_id = package_overrides
- .audit_trail_package_id
+ .audit_trail
.or_else(|| package_registry.package_id(network))
.ok_or_else(|| {
Error::InvalidConfig(format!(
@@ -105,12 +105,12 @@ pub(crate) async fn resolve_package_ids(
drop(package_registry);
let env = Env::new_with_alias(chain_id.clone(), resolved_network.as_ref());
- if let Some(audit_trail_package_id) = package_overrides.audit_trail_package_id {
+ if let Some(audit_trail_package_id) = package_overrides.audit_trail {
audit_trail_package_registry_mut()
.await
.insert_env_history(env.clone(), vec![audit_trail_package_id]);
}
- if let Some(tf_components_package_id) = package_overrides.tf_components_package_id {
+ if let Some(tf_components_package_id) = package_overrides.tf_component {
tf_components_override_registry_mut()
.await
.insert_env_history(env, vec![tf_components_package_id]);
diff --git a/audit-trail-rs/tests/e2e/access.rs b/audit-trail-rs/tests/e2e/access.rs
index cfcff048..94b4e2d0 100644
--- a/audit-trail-rs/tests/e2e/access.rs
+++ b/audit-trail-rs/tests/e2e/access.rs
@@ -73,6 +73,89 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> {
Ok(())
}
+#[tokio::test]
+async fn delegated_role_and_capability_admins_can_enable_record_writes() -> anyhow::Result<()> {
+ let admin = get_funded_test_client().await?;
+ let role_admin = get_funded_test_client().await?;
+ let cap_admin = get_funded_test_client().await?;
+ let record_admin = get_funded_test_client().await?;
+ let trail_id = admin.create_test_trail(Data::text("delegated-access-flow")).await?;
+
+ admin
+ .create_role(
+ trail_id,
+ "RoleAdmin",
+ PermissionSet::role_admin_permissions().permissions,
+ None,
+ )
+ .await?;
+ admin
+ .create_role(
+ trail_id,
+ "CapAdmin",
+ PermissionSet::cap_admin_permissions().permissions,
+ None,
+ )
+ .await?;
+ admin
+ .issue_cap(
+ trail_id,
+ "RoleAdmin",
+ CapabilityIssueOptions {
+ issued_to: Some(role_admin.sender_address()),
+ ..CapabilityIssueOptions::default()
+ },
+ )
+ .await?;
+ admin
+ .issue_cap(
+ trail_id,
+ "CapAdmin",
+ CapabilityIssueOptions {
+ issued_to: Some(cap_admin.sender_address()),
+ ..CapabilityIssueOptions::default()
+ },
+ )
+ .await?;
+
+ role_admin
+ .create_role(
+ trail_id,
+ "RecordAdmin",
+ PermissionSet::record_admin_permissions().permissions,
+ None,
+ )
+ .await?;
+ cap_admin
+ .issue_cap(
+ trail_id,
+ "RecordAdmin",
+ CapabilityIssueOptions {
+ issued_to: Some(record_admin.sender_address()),
+ ..CapabilityIssueOptions::default()
+ },
+ )
+ .await?;
+
+ let added = record_admin
+ .trail(trail_id)
+ .records()
+ .add(Data::text("delegated write"), None, None)
+ .build_and_execute(&record_admin)
+ .await?
+ .output;
+
+ assert_eq!(added.trail_id, trail_id);
+ assert_eq!(added.sequence_number, 1);
+
+ let record = admin.trail(trail_id).records().get(1).await?;
+ assert_eq!(record.sequence_number, 1);
+ assert_eq!(record.added_by, record_admin.sender_address());
+ assert_eq!(record.data, Data::text("delegated write"));
+
+ Ok(())
+}
+
#[tokio::test]
async fn create_role_rejects_undefined_role_tags() -> anyhow::Result<()> {
let client = get_funded_test_client().await?;
@@ -129,6 +212,122 @@ async fn update_role_permissions_rejects_undefined_role_tags() -> anyhow::Result
Ok(())
}
+#[tokio::test]
+async fn issue_capability_for_nonexistent_role_fails() -> anyhow::Result<()> {
+ let client = get_funded_test_client().await?;
+ let trail_id = client.create_test_trail(Data::text("missing-role-cap")).await?;
+
+ let issued = client
+ .trail(trail_id)
+ .access()
+ .for_role("NonExistentRole")
+ .issue_capability(CapabilityIssueOptions::default())
+ .build_and_execute(&client)
+ .await;
+
+ assert!(issued.is_err(), "issuing a capability for a missing role must fail");
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn issue_capability_requires_add_capabilities_permission() -> anyhow::Result<()> {
+ let admin = get_funded_test_client().await?;
+ let operator = get_funded_test_client().await?;
+ let trail_id = admin.create_test_trail(Data::text("missing-cap-permission")).await?;
+
+ admin
+ .create_role(trail_id, "NoCapPerm", vec![Permission::AddRecord], None)
+ .await?;
+ admin
+ .create_role(
+ trail_id,
+ "RecordAdmin",
+ PermissionSet::record_admin_permissions().permissions,
+ None,
+ )
+ .await?;
+ admin
+ .issue_cap(
+ trail_id,
+ "NoCapPerm",
+ CapabilityIssueOptions {
+ issued_to: Some(operator.sender_address()),
+ ..CapabilityIssueOptions::default()
+ },
+ )
+ .await?;
+
+ let issued = operator
+ .trail(trail_id)
+ .access()
+ .for_role("RecordAdmin")
+ .issue_capability(CapabilityIssueOptions::default())
+ .build_and_execute(&operator)
+ .await;
+
+ assert!(
+ issued.is_err(),
+ "issuing a capability without AddCapabilities permission must fail"
+ );
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn revoke_capability_requires_revoke_capabilities_permission() -> anyhow::Result<()> {
+ let admin = get_funded_test_client().await?;
+ let no_revoke = get_funded_test_client().await?;
+ let target = get_funded_test_client().await?;
+ let trail_id = admin.create_test_trail(Data::text("missing-revoke-permission")).await?;
+
+ admin
+ .create_role(trail_id, "NoRevokePerm", vec![Permission::AddRecord], None)
+ .await?;
+ admin
+ .create_role(
+ trail_id,
+ "RecordAdmin",
+ PermissionSet::record_admin_permissions().permissions,
+ None,
+ )
+ .await?;
+ admin
+ .issue_cap(
+ trail_id,
+ "NoRevokePerm",
+ CapabilityIssueOptions {
+ issued_to: Some(no_revoke.sender_address()),
+ ..CapabilityIssueOptions::default()
+ },
+ )
+ .await?;
+ let target_cap = admin
+ .issue_cap(
+ trail_id,
+ "RecordAdmin",
+ CapabilityIssueOptions {
+ issued_to: Some(target.sender_address()),
+ ..CapabilityIssueOptions::default()
+ },
+ )
+ .await?;
+
+ let revoked = no_revoke
+ .trail(trail_id)
+ .access()
+ .revoke_capability(target_cap.capability_id, target_cap.valid_until)
+ .build_and_execute(&no_revoke)
+ .await;
+
+ assert!(
+ revoked.is_err(),
+ "revoking a capability without RevokeCapabilities permission must fail"
+ );
+
+ Ok(())
+}
+
#[tokio::test]
async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> {
let client = get_funded_test_client().await?;
diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs
index 2b88f6d0..0bca63b4 100644
--- a/audit-trail-rs/tests/e2e/client.rs
+++ b/audit-trail-rs/tests/e2e/client.rs
@@ -172,8 +172,8 @@ impl TestClient {
let client = AuditTrailClient::from_iota_client(
iota_client.clone(),
Some(PackageOverrides {
- audit_trail_package_id: Some(package_ids.audit_trail_package_id),
- tf_components_package_id: package_ids.tf_components_package_id,
+ audit_trail: Some(package_ids.audit_trail_package_id),
+ tf_component: package_ids.tf_components_package_id,
}),
)
.await?;
diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs
index 46bab339..ea798b9c 100644
--- a/audit-trail-rs/tests/e2e/records.rs
+++ b/audit-trail-rs/tests/e2e/records.rs
@@ -9,6 +9,7 @@ use audit_trail::core::types::{
use audit_trail::error::Error;
use iota_interaction::types::base_types::ObjectID;
use product_common::core_client::CoreClient;
+use tokio::time::{Duration, sleep};
use crate::client::{TestClient, get_funded_test_client};
@@ -174,6 +175,38 @@ async fn add_tagged_record_requires_trail_defined_tag() -> anyhow::Result<()> {
Ok(())
}
+#[tokio::test]
+async fn add_record_requires_add_record_permission() -> anyhow::Result<()> {
+ let admin = get_funded_test_client().await?;
+ let writer = get_funded_test_client().await?;
+ let trail_id = admin.create_test_trail(Data::text("records-add-permission")).await?;
+ let records = writer.trail(trail_id).records();
+
+ admin
+ .create_role(trail_id, "NoAddRecord", [Permission::DeleteRecord], None)
+ .await?;
+ admin
+ .issue_cap(
+ trail_id,
+ "NoAddRecord",
+ CapabilityIssueOptions {
+ issued_to: Some(writer.sender_address()),
+ ..CapabilityIssueOptions::default()
+ },
+ )
+ .await?;
+
+ let denied = records
+ .add(Data::text("should fail"), None, None)
+ .build_and_execute(&writer)
+ .await;
+
+ assert!(denied.is_err(), "adding without AddRecord permission must fail");
+ assert_eq!(admin.trail(trail_id).records().record_count().await?, 1);
+
+ Ok(())
+}
+
#[tokio::test]
async fn add_record_selector_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> {
let client = get_funded_test_client().await?;
@@ -212,7 +245,52 @@ async fn add_record_selector_skips_revoked_capability_when_valid_one_exists() ->
assert_eq!(added.sequence_number, 1);
assert_text_data(records.get(1).await?.data, "writer record");
- // Tagged record flow.
+ Ok(())
+}
+
+#[tokio::test]
+async fn revoked_capability_cannot_add_record_without_fallback() -> anyhow::Result<()> {
+ let admin = get_funded_test_client().await?;
+ let writer = get_funded_test_client().await?;
+ let trail_id = admin.create_test_trail(Data::text("records-revoked-hard-fail")).await?;
+ let records = writer.trail(trail_id).records();
+ let role_name = "RecordWriter";
+
+ admin
+ .create_role(trail_id, role_name, [Permission::AddRecord], None)
+ .await?;
+ let issued = admin
+ .issue_cap(
+ trail_id,
+ role_name,
+ CapabilityIssueOptions {
+ issued_to: Some(writer.sender_address()),
+ ..CapabilityIssueOptions::default()
+ },
+ )
+ .await?;
+
+ admin
+ .trail(trail_id)
+ .access()
+ .revoke_capability(issued.capability_id, issued.valid_until)
+ .build_and_execute(&admin)
+ .await?;
+
+ let denied = records
+ .add(Data::text("should fail"), None, None)
+ .build_and_execute(&writer)
+ .await;
+
+ assert!(denied.is_err(), "revoked capabilities must not authorize writes");
+ assert_eq!(admin.trail(trail_id).records().record_count().await?, 1);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn add_tagged_record_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> {
+ let client = get_funded_test_client().await?;
let tagged_trail_id = client
.create_test_trail_with_tags(Data::text("records-revoked-tagged"), ["finance"])
.await?;
@@ -477,6 +555,98 @@ async fn add_record_using_capability_uses_selected_capability_without_fallback()
Ok(())
}
+#[tokio::test]
+async fn add_record_respects_valid_from_constraint() -> anyhow::Result<()> {
+ let admin = get_funded_test_client().await?;
+ let writer = get_funded_test_client().await?;
+ let trail_id = admin.create_test_trail(Data::text("records-valid-from")).await?;
+ let records = writer.trail(trail_id).records();
+ let role_name = "RecordWriter";
+
+ admin
+ .create_role(trail_id, role_name, [Permission::AddRecord], None)
+ .await?;
+ let valid_from_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)?
+ .as_millis() as u64
+ + 15_000;
+ admin
+ .issue_cap(
+ trail_id,
+ role_name,
+ CapabilityIssueOptions {
+ issued_to: Some(writer.sender_address()),
+ valid_from_ms: Some(valid_from_ms),
+ valid_until_ms: None,
+ },
+ )
+ .await?;
+
+ let denied = records
+ .add(Data::text("too early"), None, None)
+ .build_and_execute(&writer)
+ .await;
+ assert!(denied.is_err(), "writes before valid_from must fail");
+
+ sleep(Duration::from_secs(16)).await;
+
+ let added = records
+ .add(Data::text("on time"), None, None)
+ .build_and_execute(&writer)
+ .await?
+ .output;
+
+ assert_eq!(added.sequence_number, 1);
+ assert_text_data(records.get(1).await?.data, "on time");
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn add_record_respects_valid_until_constraint() -> anyhow::Result<()> {
+ let admin = get_funded_test_client().await?;
+ let writer = get_funded_test_client().await?;
+ let trail_id = admin.create_test_trail(Data::text("records-valid-until")).await?;
+ let records = writer.trail(trail_id).records();
+ let role_name = "RecordWriter";
+
+ admin
+ .create_role(trail_id, role_name, [Permission::AddRecord], None)
+ .await?;
+ let valid_until_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)?
+ .as_millis() as u64
+ + 15_000;
+ admin
+ .issue_cap(
+ trail_id,
+ role_name,
+ CapabilityIssueOptions {
+ issued_to: Some(writer.sender_address()),
+ valid_from_ms: None,
+ valid_until_ms: Some(valid_until_ms),
+ },
+ )
+ .await?;
+
+ let added = records
+ .add(Data::text("before expiry"), None, None)
+ .build_and_execute(&writer)
+ .await?
+ .output;
+ assert_eq!(added.sequence_number, 1);
+
+ sleep(Duration::from_secs(16)).await;
+
+ let denied = records
+ .add(Data::text("after expiry"), None, None)
+ .build_and_execute(&writer)
+ .await;
+ assert!(denied.is_err(), "writes after valid_until must fail");
+
+ Ok(())
+}
+
#[tokio::test]
async fn add_record_allows_mixed_data_variants() -> anyhow::Result<()> {
let client = get_funded_test_client().await?;
@@ -850,6 +1020,37 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho
Ok(())
}
+#[tokio::test]
+async fn delete_records_batch_requires_delete_all_records_permission() -> anyhow::Result<()> {
+ let admin = get_funded_test_client().await?;
+ let operator = get_funded_test_client().await?;
+ let trail_id = admin.create_test_trail(Data::text("batch-delete-permission")).await?;
+ let records = operator.trail(trail_id).records();
+
+ admin
+ .create_role(trail_id, "TrailDeleteOnly", [Permission::DeleteAuditTrail], None)
+ .await?;
+ admin
+ .issue_cap(
+ trail_id,
+ "TrailDeleteOnly",
+ CapabilityIssueOptions {
+ issued_to: Some(operator.sender_address()),
+ ..CapabilityIssueOptions::default()
+ },
+ )
+ .await?;
+
+ let denied = records.delete_records_batch(10).build_and_execute(&operator).await;
+ assert!(
+ denied.is_err(),
+ "batch deletion must require DeleteAllRecords permission"
+ );
+ assert_eq!(admin.trail(trail_id).records().record_count().await?, 1);
+
+ Ok(())
+}
+
#[tokio::test]
async fn delete_records_batch_requires_matching_role_tag_access() -> anyhow::Result<()> {
let client = get_funded_test_client().await?;
diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs
index 5506416d..4ade501b 100644
--- a/audit-trail-rs/tests/e2e/trail.rs
+++ b/audit-trail-rs/tests/e2e/trail.rs
@@ -347,6 +347,80 @@ async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result<
Ok(())
}
+#[tokio::test]
+async fn update_metadata_requires_permission() -> anyhow::Result<()> {
+ let admin = get_funded_test_client().await?;
+ let metadata_user = get_funded_test_client().await?;
+ let trail_id = admin.create_test_trail(Data::text("trail-update-meta-denied")).await?;
+
+ admin
+ .create_role(trail_id, "NoMetadataPerm", vec![Permission::AddRecord], None)
+ .await?;
+ admin
+ .issue_cap(
+ trail_id,
+ "NoMetadataPerm",
+ CapabilityIssueOptions {
+ issued_to: Some(metadata_user.sender_address()),
+ ..CapabilityIssueOptions::default()
+ },
+ )
+ .await?;
+
+ let updated = metadata_user
+ .trail(trail_id)
+ .update_metadata(Some("should fail".to_string()))
+ .build_and_execute(&metadata_user)
+ .await;
+
+ assert!(
+ updated.is_err(),
+ "updating metadata without UpdateMetadata permission must fail"
+ );
+ assert_eq!(admin.trail(trail_id).get().await?.updatable_metadata, None);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn revoked_capability_cannot_update_metadata() -> anyhow::Result<()> {
+ let admin = get_funded_test_client().await?;
+ let metadata_user = get_funded_test_client().await?;
+ let trail_id = admin.create_test_trail(Data::text("trail-update-meta-revoked")).await?;
+
+ admin
+ .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None)
+ .await?;
+ let issued = admin
+ .issue_cap(
+ trail_id,
+ "MetadataAdmin",
+ CapabilityIssueOptions {
+ issued_to: Some(metadata_user.sender_address()),
+ ..CapabilityIssueOptions::default()
+ },
+ )
+ .await?;
+
+ admin
+ .trail(trail_id)
+ .access()
+ .revoke_capability(issued.capability_id, issued.valid_until)
+ .build_and_execute(&admin)
+ .await?;
+
+ let updated = metadata_user
+ .trail(trail_id)
+ .update_metadata(Some("should fail".to_string()))
+ .build_and_execute(&metadata_user)
+ .await;
+
+ assert!(updated.is_err(), "revoked capabilities must not update metadata");
+ assert_eq!(admin.trail(trail_id).get().await?.updatable_metadata, None);
+
+ Ok(())
+}
+
#[tokio::test]
async fn delete_audit_trail_fails_when_records_exist() -> anyhow::Result<()> {
let client = get_funded_test_client().await?;
diff --git a/bindings/wasm/audit_trail_wasm/README.md b/bindings/wasm/audit_trail_wasm/README.md
index b06586e7..7a9fcf75 100644
--- a/bindings/wasm/audit_trail_wasm/README.md
+++ b/bindings/wasm/audit_trail_wasm/README.md
@@ -1,22 +1,66 @@
-# IOTA Audit Trail WASM Library
+# `audit_trail_wasm`
-`audit_trail_wasm` provides the Rust-to-WASM bindings for the `audit_trail` crate and is published to JavaScript consumers as `@iota/audit-trail`.
+`audit_trail_wasm` exposes the `audit_trail` Rust SDK to JavaScript and TypeScript consumers through `wasm-bindgen`.
-The current MVP surface includes:
+It is designed for browser and other `wasm32` environments that need:
+
+- read-only and signing audit-trail clients
+- typed wrappers for trail handles, records, locking, access control, and tags
+- serializable value and event types that map cleanly into JS/TS
+- transaction wrappers that integrate with the shared `product_common` wasm transaction helpers
+
+## Main entry points
+
+- `AuditTrailClientReadOnly` for reads and inspected transactions
+- `AuditTrailClient` for signed write flows
+- `AuditTrailBuilder` for creating new trails
+- `AuditTrailHandle` for trail-scoped APIs
+- `TrailRecords`, `TrailLocking`, `TrailAccess`, and `TrailTags` for subsystem-specific operations
+
+## Choosing an entry point
+
+- Use `AuditTrailClientReadOnly` when you need reads, package resolution, or inspected transactions.
+- Use `AuditTrailClient` when you also need typed write transaction builders.
+- Use `AuditTrailHandle` after you already know the trail object ID and want to stay scoped to that trail.
+- Use `AuditTrailBuilder` when you are preparing a create-trail transaction.
+
+## Data model wrappers
+
+The bindings expose JS-friendly wrappers for the most important Rust value types:
-- `AuditTrailClientReadOnly`
-- `AuditTrailClient`
-- `AuditTrailBuilder`
-- `AuditTrailHandle`
-- `TrailRecords`
- `Data`
-- `Record`
-- `PaginatedRecord`
-- `OnChainAuditTrail`
-- `ImmutableMetadata`
-- `LockingConfig`
-- `LockingWindow`
-- `TimeLock`
+- `Permission` and `PermissionSet`
+- `RoleTags`, `RoleMap`, and `CapabilityIssueOptions`
+- `TimeLock`, `LockingWindow`, and `LockingConfig`
+- `Record`, `PaginatedRecord`, and `OnChainAuditTrail`
+- event payloads such as `RecordAdded`, `RoleCreated`, and `CapabilityIssued`
+
+## Typical read flow
+
+1. Create an `AuditTrailClientReadOnly` or `AuditTrailClient`.
+2. Resolve a trail handle with `.trail(trailId)`.
+3. Read state with `.get()`, `.records().get(...)`, `.records().listPage(...)`, or `.locking().isRecordLocked(...)`.
+
+## Typical write flow
+
+1. Create an `AuditTrailClient` with a transaction signer.
+2. Build a transaction from `client.createTrail()`, `client.trail(trailId)`, or one of the trail subsystem handles.
+3. Convert that transaction wrapper into programmable transaction bytes.
+4. Submit it through your surrounding JS transaction flow and feed the effects and events back into the typed `applyWithEvents(...)` helper.
+
+The bindings intentionally separate transaction construction from submission so browser apps, wallet integrations, and server-side signing flows can keep transport and execution policy outside the SDK.
+
+## Minimal TypeScript shape
+
+```ts
+import { AuditTrailClientReadOnly } from "@iota/audit-trail-wasm";
+
+const client = await AuditTrailClientReadOnly.create(iotaClient);
+const trail = client.trail(trailId);
+const state = await trail.get();
+
+console.log(state.sequenceNumber);
+```
## Build
@@ -27,4 +71,4 @@ npm run build
## Examples
-See [examples/README.md](./examples/README.md) for the node example flows.
+See [examples/README.md](./examples/README.md) for runnable node and web example flows.
diff --git a/bindings/wasm/audit_trail_wasm/src/builder.rs b/bindings/wasm/audit_trail_wasm/src/builder.rs
index c79b18f8..0db57672 100644
--- a/bindings/wasm/audit_trail_wasm/src/builder.rs
+++ b/bindings/wasm/audit_trail_wasm/src/builder.rs
@@ -11,16 +11,19 @@ use wasm_bindgen::prelude::*;
use crate::trail::WasmCreateTrail;
use crate::types::WasmLockingConfig;
+/// Trail-creation builder exposed to wasm consumers.
#[wasm_bindgen(js_name = AuditTrailBuilder, inspectable)]
pub struct WasmAuditTrailBuilder(pub(crate) AuditTrailBuilder);
#[wasm_bindgen(js_class = AuditTrailBuilder)]
impl WasmAuditTrailBuilder {
+ /// Sets the initial record using a UTF-8 string payload.
#[wasm_bindgen(js_name = withInitialRecordString)]
pub fn with_initial_record_string(self, data: String, metadata: Option, tag: Option) -> Self {
Self(self.0.with_initial_record_parts(data, metadata, tag))
}
+ /// Sets the initial record using raw bytes.
#[wasm_bindgen(js_name = withInitialRecordBytes)]
pub fn with_initial_record_bytes(
self,
@@ -31,32 +34,38 @@ impl WasmAuditTrailBuilder {
Self(self.0.with_initial_record_parts(data.to_vec(), metadata, tag))
}
+ /// Sets immutable metadata for the trail.
#[wasm_bindgen(js_name = withTrailMetadata)]
pub fn with_trail_metadata(self, name: String, description: Option) -> Self {
Self(self.0.with_trail_metadata_parts(name, description))
}
+ /// Sets mutable metadata for the trail.
#[wasm_bindgen(js_name = withUpdatableMetadata)]
pub fn with_updatable_metadata(self, metadata: String) -> Self {
Self(self.0.with_updatable_metadata(metadata))
}
+ /// Sets the locking configuration for the trail.
#[wasm_bindgen(js_name = withLockingConfig)]
pub fn with_locking_config(self, config: WasmLockingConfig) -> Self {
Self(self.0.with_locking_config(config.into()))
}
+ /// Sets the canonical list of record tags owned by the trail.
#[wasm_bindgen(js_name = withRecordTags)]
pub fn with_record_tags(self, tags: Vec) -> Self {
Self(self.0.with_record_tags(tags))
}
+ /// Sets the initial admin address.
#[wasm_bindgen(js_name = withAdmin)]
pub fn with_admin(self, admin: WasmIotaAddress) -> Result {
let admin = parse_wasm_iota_address(&admin)?;
Ok(Self(self.0.with_admin(admin)))
}
+ /// Finalizes the builder into a transaction wrapper.
#[wasm_bindgen(unchecked_return_type = "TransactionBuilder")]
pub fn finish(self) -> Result {
Ok(into_transaction_builder(WasmCreateTrail::new(self)))
diff --git a/bindings/wasm/audit_trail_wasm/src/client.rs b/bindings/wasm/audit_trail_wasm/src/client.rs
index a09a5ea1..acc65f13 100644
--- a/bindings/wasm/audit_trail_wasm/src/client.rs
+++ b/bindings/wasm/audit_trail_wasm/src/client.rs
@@ -14,12 +14,17 @@ use crate::builder::WasmAuditTrailBuilder;
use crate::client_read_only::{WasmAuditTrailClientReadOnly, WasmPackageOverrides};
use crate::trail_handle::WasmAuditTrailHandle;
+/// Signing audit-trail client exposed to wasm consumers.
+///
+/// This wraps the read-only client with a transaction signer so JS/TS consumers can build typed
+/// write transactions while keeping submission and execution outside the SDK.
#[derive(Clone)]
#[wasm_bindgen(js_name = AuditTrailClient)]
pub struct WasmAuditTrailClient(pub(crate) AuditTrailClient