diff --git a/APPROACH_STATEMENT_ISSUE_524.md b/APPROACH_STATEMENT_ISSUE_524.md new file mode 100644 index 0000000..d46efb8 --- /dev/null +++ b/APPROACH_STATEMENT_ISSUE_524.md @@ -0,0 +1,172 @@ +# Approach Statement — Issue #524: Add Asset Restore Function + +## Reconnaissance Summary + +### Asset Status and Lifecycle +- **Current `AssetStatus` enum** (asset_registry.rs, line ~95): + - `Active` — asset is active and operational + - `Paused` — monitoring paused but recoverable + - `Deprecated` — read-only, not restorable + - `PendingReview` — awaiting activation + - **Missing**: `Deactivated` status variant needed for restore functionality + +- **Lifecycle transition rules** (asset_registry.rs, lines ~623–631): + - Valid transitions: `PendingReview → Active`, `Active ↔ Paused`, `Active|Paused → Deprecated` + - **Invalid**: Deprecated → any other state (permanent) + - **To be added**: `Active → Deactivated` and `Deactivated → Active` transitions + +### Asset Metadata Structure +- **AssetMetadata struct** (asset_registry.rs, lines ~136–160): + - Fields to preserve during deactivation/restoration: + - `asset_code`, `name`, `symbol`, `issuer`, `decimals`: immutable identifiers (preserved) + - `category`, `description`, `url`, `registered_at`, `registered_by`: historical (preserved) + - `compliance`, `risk_rating`, `risk_score_bps`: asset properties (preserved) + - `version`, `updated_at`: versioned on update (modify on each operation) + - **No explicit `deactivated_at` field exists** — will use `updated_at` for timestamp tracking + - Status field: `status` — transitions between `Active ↔ Deactivated` + +- **Metadata versioning** (MetadataVersion struct, lines ~266–274): + - Every state change is tracked: `version` increments, `changed_by` recorded, `change_reason` stored, `timestamp` recorded + - Restoration will append a new version entry with reason "Asset restored" + +### Storage Pattern +- **DataKey enum** (asset_registry.rs, lines ~300–321): + - Asset core data: `DataKey::AssetMeta(String)` + - Associated lists: `DataKey::Versions(String)`, `DataKey::StatusIndex(AssetStatus)`, etc. + - Storage tier: `env.storage().persistent()` for all asset data + - **No explicit TTL management** observed in asset registry (distinct from relay contract which bumps TTL) + - Access pattern: `env.storage().persistent().set/get/has(&key)` + +### Permission Model +- **Admin check helper** (asset_registry.rs, lines ~1202–1215): + - `require_admin(&env, &caller) → Result<(), RegistryError>` + - Caller must be the stored admin (pattern: `caller.require_auth()`) + - Used by: all write operations (register_asset, update_status, freeze_asset, etc.) + - **Deactivation and restoration**: will require admin permission, consistent with other lifecycle operations + +### Error Handling +- **RegistryError enum** (asset_registry.rs, lines ~49–69): + - Current codes: 1–20, sequential without gaps + - Pattern: `#[contracterror] #[derive(...)] #[repr(u32)] pub enum RegistryError { Variant = code, }` + - Variant 20 = `AssetFrozen`, so next available codes: **21, 22, 23, ...** + - **New errors needed**: + - `AssetAlreadyActive = 21` — attempted to deactivate an already-active asset (redundant case) + - `AssetNotDeactivated = 22` — attempting to restore an asset that is not in Deactivated status + - Note: `AssetNotFound` already exists (code 4) and will be reused + +### Event Emission Pattern +- **Event publishing** (asset_registry.rs, examples at lines ~659, ~727, etc.): + - Pattern: `env.events().publish((symbol_short!("topic"), asset_code), data)` + - Topic is a tuple: `(Symbol, String)` where Symbol is short identifier + - Event topics in use: `ar_stat` (status), `ar_cat` (category), `ar_risk` (risk), etc. + - Data payload: varies (u32, 1u32, etc.) + - **New events needed**: + - `("asset_deactivated", asset_code)` with data: admin address (for audit trail) + - `("asset_restored", asset_code)` with data: admin address (for audit trail) + +### Existing Tests +- **Test framework**: Soroban #[test] with testutils (soroban_sdk::testutils) +- **Test pattern** (asset_registry.rs, lines ~1318+): + - Setup helper: `fn setup() → (Env, AssetRegistryContractClient, Address)` + - Register helper: `fn register_usdc(...) → String` + - Assertions: `assert_eq!`, `assert!`, `.unwrap()`, `.is_err()` + - Mock auth: `env.mock_all_auths()` + - Tests are marked `#[cfg(test)]` and grouped by feature area + +## Implementation Plan + +### 1. Add Deactivated Status +- **File**: `contracts/soroban/src/asset_registry.rs`, AssetStatus enum +- **Change**: Add variant `Deactivated,` with doc comment "Asset is deactivated; awaiting restoration" + +### 2. Add Error Variants +- **File**: `contracts/soroban/src/asset_registry.rs`, RegistryError enum +- Add: + - `AssetAlreadyActive = 21` — attempted to deactivate an asset that is already active (defensive) + - `AssetNotDeactivated = 22` — attempted to restore an asset that is not in deactivated state + +### 3. Implement deactivate_asset Function +- **File**: `contracts/soroban/src/asset_registry.rs`, AssetRegistryContract impl +- **Function signature**: + ```rust + pub fn deactivate_asset( + env: Env, + admin: Address, + asset_code: String, + reason: String, + ) -> Result<(), RegistryError> + ``` +- **Logic**: + - Step 1: `Self::require_admin(&env, &admin)?` — auth check + - Step 2: Load metadata, fail if not found (`AssetNotFound`) + - Step 3: Verify asset is currently `Active` (not already deactivated/deprecated), else `AssetAlreadyActive` + - Step 4: Update status to `Deactivated`, increment version, update timestamp + - Step 5: Remove from `StatusIndex(Active)`, add to `StatusIndex(Deactivated)` + - Step 6: Call `save_with_version` to record version entry + - Step 7: Emit event: `publish((symbol_short!("asset_deact"), asset_code), admin)` + - Step 8: Return `Ok(())` + +### 4. Implement restore_asset Function +- **File**: `contracts/soroban/src/asset_registry.rs`, AssetRegistryContract impl +- **Function signature**: + ```rust + pub fn restore_asset( + env: Env, + admin: Address, + asset_code: String, + ) -> Result<(), RegistryError> + ``` +- **Logic**: + - Step 1: `Self::require_admin(&env, &admin)?` — auth check + - Step 2: Load metadata, fail if not found (`AssetNotFound`) + - Step 3: Verify asset is in `Deactivated` status, else `AssetNotDeactivated` + - Step 4: Restore status to `Active`, increment version, update timestamp + - Step 5: Remove from `StatusIndex(Deactivated)`, add to `StatusIndex(Active)` + - Step 6: Call `save_with_version` to record version entry with reason "Asset restored" + - Step 7: Emit event: `publish((symbol_short!("asset_rest"), asset_code), admin)` + - Step 8: Return `Ok(())` + +### 5. Update Lifecycle Transition Rules +- **File**: `asset_registry.rs`, update_status function validation logic +- **Current rule** (lines ~623–631): transitions validated in matches! macro +- **Addition**: Add rules: + - `(Active, Deactivated)` — allowed (via dedicated deactivate_asset function, but allow in update_status too for consistency) + - `(Deactivated, Active)` — **NOT** allowed here (only via dedicated restore_asset function to ensure proper version tracking) +- **Rationale**: deactivate/restore are intentional operations with dedicated functions; update_status remains for standard lifecycle + +### 6. Tests to Implement +- **Happy path**: Deactivate active asset → verify status = Deactivated, version incremented, event emitted +- **State continuity**: Before deactivation, record all fields; after restoration, verify all non-status fields unchanged +- **Error: restore non-deactivated**: Try to restore an Active asset → AssetNotDeactivated +- **Error: deactivate non-active**: Try to deactivate a Paused or Deprecated asset → AssetAlreadyActive +- **Error: unknown asset**: Deactivate/restore non-existent asset code → AssetNotFound +- **Error: unauthorized**: Deactivate/restore from non-admin address → auth error, no state change +- **Idempotency**: Deactivate→restore→deactivate→restore yields same consistent state each time +- **Version history**: 3 version entries: registration, deactivation, restoration + +### 7. Documentation Updates +- **File**: `docs/asset-lifecycle.md` (if exists, or to be created) + - Document `Deactivated` status and when it's used + - Document `deactivate_asset` and `restore_asset` functions + - Add permission requirements (admin only) + - Confirm state continuity guarantee +- **Inline doc comments**: Full /// doc comments on new functions and error variants + +## Files to Modify +1. `contracts/soroban/src/asset_registry.rs` — main implementation (enum, functions, errors, tests) +2. `docs/asset-lifecycle.md` (or create) — lifecycle documentation + +## Files to Create +- None (all changes in existing asset_registry.rs) + +## Unresolved Questions +- None — specification is clear from codebase patterns + +## CI Requirements (from .github/workflows/ci.yml) +- `cargo fmt --all -- --check` — formatting +- `cargo clippy -- -D warnings` — linting +- `cargo build --verbose` — compilation +- `cargo test --verbose` — all tests including new restoration tests + +## Confidence Level +**High** — all patterns from existing code are well-established and the implementation closely mirrors freeze_asset/unfreeze_asset and update_status patterns. diff --git a/contracts/soroban/src/asset_registry.rs b/contracts/soroban/src/asset_registry.rs index 26cddb8..340a080 100644 --- a/contracts/soroban/src/asset_registry.rs +++ b/contracts/soroban/src/asset_registry.rs @@ -68,6 +68,12 @@ pub enum RegistryError { AssetNotWhitelisted = 18, AssetAlreadyWhitelisted = 19, AssetFrozen = 20, + /// Attempted to deactivate an asset that is already in a non-restorable state or already active. + /// Deactivation is only valid for Active assets. Check the asset's current status. + AssetAlreadyActive = 21, + /// Attempted to restore an asset that is not in a Deactivated state. + /// Only deactivated assets can be restored. Use the asset's current status to determine next actions. + AssetNotDeactivated = 22, } // --------------------------------------------------------------------------- @@ -104,6 +110,8 @@ pub enum AssetStatus { Deprecated, /// Asset is pending review before activation. PendingReview, + /// Asset has been deactivated; awaiting restoration. All historical data is preserved. + Deactivated, } /// Compliance status for regulatory tracking. @@ -634,6 +642,8 @@ impl AssetRegistryContract { | (AssetStatus::Paused, AssetStatus::Active) | (AssetStatus::Active, AssetStatus::Deprecated) | (AssetStatus::Paused, AssetStatus::Deprecated) + | (AssetStatus::Active, AssetStatus::Deactivated) + | (AssetStatus::Paused, AssetStatus::Deactivated) ); if !valid { return Err(RegistryError::InvalidLifecycleTransition); @@ -668,8 +678,156 @@ impl AssetRegistryContract { } // ======================================================================= - // Compliance tracking + // Asset deactivation and restoration + // ======================================================================= + + /// Deactivate an active asset while preserving all historical data (admin only). + /// + /// Transitions an asset from Active to Deactivated state, recording the change in version + /// history. All asset metadata, chain links, compliance records, oracle feeds, and other + /// associations are preserved intact. A deactivated asset can be restored at any time + /// via [`restore_asset`]. + /// + /// # Arguments + /// * `env` — the contract environment + /// * `admin` — the caller, must be the contract admin + /// * `asset_code` — unique identifier for the asset to deactivate + /// * `reason` — human-readable explanation for deactivation + /// + /// # Returns + /// `Ok(())` if deactivation succeeds, or an error: + /// - `NotAuthorized` if caller is not admin + /// - `AssetNotFound` if the asset_code does not exist + /// - `AssetAlreadyActive` if the asset is not currently Active (already deactivated, paused, deprecated, or pending) + /// + /// # Events + /// Emits `(symbol_short!("asset_deact"), asset_code)` with admin address data. + /// + /// # State Continuity + /// All metadata fields except `status`, `version`, and `updated_at` are preserved unchanged. + /// The deactivation is recorded as a new version entry in the asset's history. + pub fn deactivate_asset( + env: Env, + admin: Address, + asset_code: String, + reason: String, + ) -> Result<(), RegistryError> { + Self::require_admin(&env, &admin)?; + let mut metadata = Self::get_asset_or_err(&env, &asset_code)?; + + // Verify asset is currently Active (only Active assets can be deactivated) + if metadata.status != AssetStatus::Active { + return Err(RegistryError::AssetAlreadyActive); + } + + // Update status indices: remove from Active, add to Deactivated + Self::remove_from_index( + &env, + &DataKey::StatusIndex(AssetStatus::Active), + &asset_code, + ); + Self::add_to_index( + &env, + &DataKey::StatusIndex(AssetStatus::Deactivated), + &asset_code, + ); + + // Transition to Deactivated status + metadata.status = AssetStatus::Deactivated; + metadata.version += 1; + metadata.updated_at = env.ledger().timestamp(); + + // Save with version tracking + Self::save_with_version( + &env, + &asset_code, + &metadata, + &admin, + &reason, + metadata.updated_at, + ); + + // Emit deactivation event + env.events() + .publish((symbol_short!("asset_deact"), asset_code.clone()), admin); + + Ok(()) + } + + /// Restore a deactivated asset to Active state (admin only). + /// + /// Transitions an asset from Deactivated back to Active state, preserving all historical + /// metadata, chain links, compliance records, oracle feeds, and other associations. + /// The restoration is recorded as a new version entry. + /// + /// # Arguments + /// * `env` — the contract environment + /// * `admin` — the caller, must be the contract admin + /// * `asset_code` — unique identifier for the asset to restore + /// + /// # Returns + /// `Ok(())` if restoration succeeds, or an error: + /// - `NotAuthorized` if caller is not admin + /// - `AssetNotFound` if the asset_code does not exist + /// - `AssetNotDeactivated` if the asset is not currently Deactivated (e.g. already Active, Paused, Deprecated) + /// + /// # Events + /// Emits `(symbol_short!("asset_rest"), asset_code)` with admin address data. + /// + /// # State Continuity + /// All metadata fields except `status`, `version`, and `updated_at` are restored unchanged. + /// The entire asset history, including the deactivation event and prior versions, remains intact. + pub fn restore_asset( + env: Env, + admin: Address, + asset_code: String, + ) -> Result<(), RegistryError> { + Self::require_admin(&env, &admin)?; + let mut metadata = Self::get_asset_or_err(&env, &asset_code)?; + + // Verify asset is currently Deactivated (only Deactivated assets can be restored) + if metadata.status != AssetStatus::Deactivated { + return Err(RegistryError::AssetNotDeactivated); + } + + // Update status indices: remove from Deactivated, add to Active + Self::remove_from_index( + &env, + &DataKey::StatusIndex(AssetStatus::Deactivated), + &asset_code, + ); + Self::add_to_index( + &env, + &DataKey::StatusIndex(AssetStatus::Active), + &asset_code, + ); + + // Transition to Active status + metadata.status = AssetStatus::Active; + metadata.version += 1; + metadata.updated_at = env.ledger().timestamp(); + + // Save with version tracking + let reason = String::from_str(&env, "Asset restored"); + Self::save_with_version( + &env, + &asset_code, + &metadata, + &admin, + &reason, + metadata.updated_at, + ); + + // Emit restoration event + env.events() + .publish((symbol_short!("asset_rest"), asset_code.clone()), admin); + + Ok(()) + } + // ======================================================================= + // Compliance tracking + // =======================================================================" /// Update compliance status and add a compliance record (admin only). #[allow(clippy::too_many_arguments)] @@ -1701,6 +1859,294 @@ mod tests { assert_eq!(result, Err(Ok(RegistryError::AssetDeprecated))); } + // ----------------------------------------------------------------------- + // Asset deactivation and restoration + // ----------------------------------------------------------------------- + + #[test] + fn test_deactivate_asset_happy_path() { + let (env, client, admin) = setup(); + let asset_code = register_usdc(&env, &client, &admin); + + client.update_status(&admin, &asset_code, &AssetStatus::Active); + + // Verify asset is active before deactivation + let meta = client.get_asset(&asset_code).unwrap(); + assert_eq!(meta.status, AssetStatus::Active); + let version_before = meta.version; + + // Deactivate the asset + client.deactivate_asset( + &admin, + &asset_code, + &String::from_str(&env, "Temporary suspension"), + ); + + // Verify asset is now deactivated + let meta = client.get_asset(&asset_code).unwrap(); + assert_eq!(meta.status, AssetStatus::Deactivated); + assert_eq!(meta.version, version_before + 1); + + // Verify it appears in the Deactivated index + let deactivated = client.get_assets_by_status(&AssetStatus::Deactivated); + assert_eq!(deactivated.len(), 1); + assert_eq!(deactivated.get(0).unwrap(), asset_code); + } + + #[test] + fn test_restore_asset_happy_path() { + let (env, client, admin) = setup(); + let asset_code = register_usdc(&env, &client, &admin); + + // Setup: activate and deactivate + client.update_status(&admin, &asset_code, &AssetStatus::Active); + client.deactivate_asset( + &admin, + &asset_code, + &String::from_str(&env, "Temporary suspension"), + ); + + // Verify deactivated + let meta = client.get_asset(&asset_code).unwrap(); + assert_eq!(meta.status, AssetStatus::Deactivated); + let version_before = meta.version; + + // Restore the asset + client.restore_asset(&admin, &asset_code); + + // Verify restored to Active + let meta = client.get_asset(&asset_code).unwrap(); + assert_eq!(meta.status, AssetStatus::Active); + assert_eq!(meta.version, version_before + 1); + + // Verify it appears in the Active index + let active = client.get_assets_by_status(&AssetStatus::Active); + assert_eq!(active.len(), 1); + assert_eq!(active.get(0).unwrap(), asset_code); + } + + #[test] + fn test_deactivate_non_active_asset_fails() { + let (env, client, admin) = setup(); + let asset_code = register_usdc(&env, &client, &admin); + + // Do not activate; asset is still PendingReview + let result = client.try_deactivate_asset( + &admin, + &asset_code, + &String::from_str(&env, "Unexpected deactivation"), + ); + assert_eq!(result, Err(Ok(RegistryError::AssetAlreadyActive))); + } + + #[test] + fn test_restore_non_deactivated_asset_fails() { + let (env, client, admin) = setup(); + let asset_code = register_usdc(&env, &client, &admin); + + // Activate the asset + client.update_status(&admin, &asset_code, &AssetStatus::Active); + + // Try to restore an Active (not Deactivated) asset + let result = client.try_restore_asset(&admin, &asset_code); + assert_eq!(result, Err(Ok(RegistryError::AssetNotDeactivated))); + } + + #[test] + fn test_deactivate_nonexistent_asset_fails() { + let (env, client, admin) = setup(); + let nonexistent = String::from_str(&env, "FAKE"); + + let result = client.try_deactivate_asset( + &admin, + &nonexistent, + &String::from_str(&env, "Attempt"), + ); + assert_eq!(result, Err(Ok(RegistryError::AssetNotFound))); + } + + #[test] + fn test_restore_nonexistent_asset_fails() { + let (env, client, admin) = setup(); + let nonexistent = String::from_str(&env, "FAKE"); + + let result = client.try_restore_asset(&admin, &nonexistent); + assert_eq!(result, Err(Ok(RegistryError::AssetNotFound))); + } + + #[test] + fn test_deactivate_unauthorized_fails() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AssetRegistryContract); + let client = AssetRegistryContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let unauthorized = Address::generate(&env); + + client.initialize(&admin); + let asset_code = register_usdc(&env, &client, &admin); + client.update_status(&admin, &asset_code, &AssetStatus::Active); + + // Try to deactivate from unauthorized address + let result = client.try_deactivate_asset( + &unauthorized, + &asset_code, + &String::from_str(&env, "Unauthorized"), + ); + assert!(result.is_err()); + + // Verify asset remains active (no state change) + let meta = client.get_asset(&asset_code).unwrap(); + assert_eq!(meta.status, AssetStatus::Active); + } + + #[test] + fn test_restore_unauthorized_fails() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AssetRegistryContract); + let client = AssetRegistryContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let unauthorized = Address::generate(&env); + + client.initialize(&admin); + let asset_code = register_usdc(&env, &client, &admin); + client.update_status(&admin, &asset_code, &AssetStatus::Active); + client.deactivate_asset( + &admin, + &asset_code, + &String::from_str(&env, "Temporary"), + ); + + // Try to restore from unauthorized address + let result = client.try_restore_asset(&unauthorized, &asset_code); + assert!(result.is_err()); + + // Verify asset remains deactivated (no state change) + let meta = client.get_asset(&asset_code).unwrap(); + assert_eq!(meta.status, AssetStatus::Deactivated); + } + + #[test] + fn test_deactivate_restore_idempotency() { + let (env, client, admin) = setup(); + let asset_code = register_usdc(&env, &client, &admin); + + client.update_status(&admin, &asset_code, &AssetStatus::Active); + + // First cycle: deactivate and restore + client.deactivate_asset( + &admin, + &asset_code, + &String::from_str(&env, "First deactivation"), + ); + client.restore_asset(&admin, &asset_code); + + let meta1 = client.get_asset(&asset_code).unwrap(); + assert_eq!(meta1.status, AssetStatus::Active); + let version_after_first = meta1.version; + + // Second cycle: deactivate and restore again + client.deactivate_asset( + &admin, + &asset_code, + &String::from_str(&env, "Second deactivation"), + ); + client.restore_asset(&admin, &asset_code); + + let meta2 = client.get_asset(&asset_code).unwrap(); + assert_eq!(meta2.status, AssetStatus::Active); + // Version should continue incrementing, not reset + assert_eq!(meta2.version, version_after_first + 2); + } + + #[test] + fn test_state_continuity_deactivate_restore() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AssetRegistryContract); + let client = AssetRegistryContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize(&admin); + + // Register asset with specific metadata + let asset_code = String::from_str(&env, "LEGACY"); + client.register_asset( + &admin, + &asset_code, + &String::from_str(&env, "Legacy Token"), + &String::from_str(&env, "LEG"), + &String::from_str(&env, "legacy.com"), + 8, + &AssetCategory::Other, + &String::from_str(&env, "Historical asset"), + &String::from_str(&env, "https://legacy.com"), + ); + + // Activate and record base state + client.update_status(&admin, &asset_code, &AssetStatus::Active); + let base_meta = client.get_asset(&asset_code).unwrap(); + + // Deactivate + client.deactivate_asset( + &admin, + &asset_code, + &String::from_str(&env, "Archived"), + ); + + // Restore + client.restore_asset(&admin, &asset_code); + let restored_meta = client.get_asset(&asset_code).unwrap(); + + // Verify all non-status fields are identical + assert_eq!(restored_meta.asset_code, base_meta.asset_code); + assert_eq!(restored_meta.name, base_meta.name); + assert_eq!(restored_meta.symbol, base_meta.symbol); + assert_eq!(restored_meta.issuer, base_meta.issuer); + assert_eq!(restored_meta.decimals, base_meta.decimals); + assert_eq!(restored_meta.category, base_meta.category); + assert_eq!(restored_meta.compliance, base_meta.compliance); + assert_eq!(restored_meta.risk_rating, base_meta.risk_rating); + assert_eq!(restored_meta.risk_score_bps, base_meta.risk_score_bps); + assert_eq!(restored_meta.description, base_meta.description); + assert_eq!(restored_meta.url, base_meta.url); + assert_eq!(restored_meta.registered_at, base_meta.registered_at); + assert_eq!(restored_meta.registered_by, base_meta.registered_by); + // Status must change + assert_eq!(restored_meta.status, AssetStatus::Active); + // Version incremented twice (once for deactivate, once for restore) + assert_eq!(restored_meta.version, base_meta.version + 2); + } + + #[test] + fn test_version_history_tracks_deactivation() { + let (env, client, admin) = setup(); + let asset_code = register_usdc(&env, &client, &admin); + + client.update_status(&admin, &asset_code, &AssetStatus::Active); + + // De activate the asset + client.deactivate_asset( + &admin, + &asset_code, + &String::from_str(&env, "Maintenance break"), + ); + + // Restore the asset + client.restore_asset(&admin, &asset_code); + + // Check version history: should have 3 entries (registration, deactivation, restoration) + let versions = client.get_metadata_versions(&asset_code); + assert!(versions.len() >= 3); + + // Latest version should reflect Active status + let latest = versions.get(versions.len() - 1).unwrap(); + assert_eq!(latest.metadata.status, AssetStatus::Active); + } + // ----------------------------------------------------------------------- // Compliance tracking // -----------------------------------------------------------------------