lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 8d6062bc9ecc71d724c767e85d45f918c18fa33e
parent 1cc197c2d48cd537de50e5f2637e00bf2c10fd36
Author: triesap <tyson@radroots.org>
Date:   Tue, 22 Sep 2026 04:39:24 +0000

storage: expose canonical backup capability

- Delegate actual capture and finalization through the SDK
- Refuse unsupported backends with path-free typed errors
- Preserve existing manifests and owner snapshot semantics
- Qualify WAL capture, API compatibility and owner coverage

Diffstat:
Mcontracts/api_baselines/radroots_sdk.txt | 3+++
Mcontracts/api_baselines/radroots_storage.txt | 26++++++++++++++++++++++++++
Mcontracts/api_baselines/radroots_storage_sqlite.txt | 3+++
Acontracts/architecture/decisions/storage_backup_capability.v1.json | 12++++++++++++
Mcrates/sdk/README.md | 8++++++++
Mcrates/sdk/src/storage.rs | 35+++++++++++++++++++++++++++++++++--
Acrates/sdk/src/storage/backup_tests.rs | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/package_boundary.rs | 1+
Mcrates/storage/README.md | 16++++++++++++++--
Mcrates/storage/src/backup.rs | 32++++++++++++++++++++++++++++++++
Acrates/storage/src/backup/capability.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage_sqlite/src/backup.rs | 48++++++++++++++++++++++++++++++++++++++++++++----
Acrates/storage_sqlite/src/backup/capability.rs | 17+++++++++++++++++
Acrates/storage_sqlite/src/backup/capability_tests.rs | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
14 files changed, 613 insertions(+), 8 deletions(-)

diff --git a/contracts/api_baselines/radroots_sdk.txt b/contracts/api_baselines/radroots_sdk.txt @@ -339,10 +339,13 @@ pub struct radroots_sdk::storage::Operations<'a> impl<'a> radroots_sdk::storage::Operations<'a> pub async fn radroots_sdk::storage::Operations<'a>::begin_backup(&self, radroots_storage::backup::BackupPlan) -> core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::error::Error> pub async fn radroots_sdk::storage::Operations<'a>::begin_restore(&self, radroots_storage::backup::RestorePlan) -> core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::error::Error> +pub async fn radroots_sdk::storage::Operations<'a>::capture_backup(&self, radroots_storage::backup::BackupPlan) -> core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::capability::BackupCapabilityError> +pub async fn radroots_sdk::storage::Operations<'a>::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError> pub async fn radroots_sdk::storage::Operations<'a>::integrity(&self) -> core::result::Result<radroots_sdk::storage::IntegrityStatus, radroots_storage::error::Error> pub async fn radroots_sdk::storage::Operations<'a>::status(&self) -> core::result::Result<radroots_sdk::storage::Status, radroots_storage::error::Error> pub async fn radroots_sdk::storage::Operations<'a>::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::error::Error> pub async fn radroots_sdk::storage::Operations<'a>::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::error::Error> +pub async fn radroots_sdk::storage::Operations<'a>::verify_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError> impl core::fmt::Debug for radroots_sdk::storage::Operations<'_> pub fn radroots_sdk::storage::Operations<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub type radroots_sdk::storage::IntegrityStatus = radroots_storage::status::IntegrityStatus diff --git a/contracts/api_baselines/radroots_storage.txt b/contracts/api_baselines/radroots_storage.txt @@ -582,6 +582,17 @@ pub fn radroots_storage::authored_draft_submission::PrepareFromDraft::validate(& impl core::fmt::Debug for radroots_storage::authored_draft_submission::PrepareFromDraft pub fn radroots_storage::authored_draft_submission::PrepareFromDraft::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub mod radroots_storage::backup +#[non_exhaustive] pub enum radroots_storage::backup::BackupCapabilityError +pub radroots_storage::backup::BackupCapabilityError::Conflict +pub radroots_storage::backup::BackupCapabilityError::Failed +pub radroots_storage::backup::BackupCapabilityError::InvalidConfiguration +pub radroots_storage::backup::BackupCapabilityError::Unavailable +pub radroots_storage::backup::BackupCapabilityError::Unsupported +pub radroots_storage::backup::BackupCapabilityError::UnsupportedVersion +pub radroots_storage::backup::BackupCapabilityError::VerificationFailed +impl core::error::Error for radroots_storage::backup::BackupCapabilityError +impl core::fmt::Display for radroots_storage::backup::BackupCapabilityError +pub fn radroots_storage::backup::BackupCapabilityError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub enum radroots_storage::backup::BackupMemberKind pub radroots_storage::backup::BackupMemberKind::Metadata pub radroots_storage::backup::BackupMemberKind::Protected @@ -709,19 +720,25 @@ pub const radroots_storage::backup::BACKUP_MEMBER_PATH_MAX_BYTES: usize pub trait radroots_storage::backup::StorageReliability: core::marker::Send + core::marker::Sync pub fn radroots_storage::backup::StorageReliability::begin_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::backup::StorageReliability::begin_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> +pub fn radroots_storage::backup::StorageReliability::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::backup::StorageReliability::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> +pub fn radroots_storage::backup::StorageReliability::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::backup::StorageReliability::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> pub fn radroots_storage::backup::StorageReliability::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::backup::StorageReliability::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::backup::StorageReliability::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> +pub fn radroots_storage::backup::StorageReliability::verify_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> impl radroots_storage::backup::StorageReliability for radroots_storage::memory::MemoryStorage pub fn radroots_storage::memory::MemoryStorage::begin_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::begin_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::verify_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub mod radroots_storage::event pub use radroots_storage::event::BoxFuture pub use radroots_storage::event::EventId @@ -977,11 +994,14 @@ pub fn radroots_storage::memory::MemoryStorage::query_authored_drafts(&self, rad impl radroots_storage::backup::StorageReliability for radroots_storage::memory::MemoryStorage pub fn radroots_storage::memory::MemoryStorage::begin_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::begin_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::verify_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> impl radroots_storage::event::EventStore for radroots_storage::memory::MemoryStorage pub fn radroots_storage::memory::MemoryStorage::admit(&self, radroots_storage::event::EventAdmission) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::event::AdmissionReceipt, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::query_provenance(&self, radroots_event::id::EventId, radroots_storage::event::EventQueryBounds) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::event::EventPage<radroots_storage::event::StoredEventProvenance>, radroots_storage::Error>> @@ -1818,19 +1838,25 @@ pub const fn radroots_storage::status::StorageStatus::writer_policy(self) -> rad pub trait radroots_storage::BackupSource: core::marker::Send + core::marker::Sync pub fn radroots_storage::BackupSource::begin_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::BackupSource::begin_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> +pub fn radroots_storage::BackupSource::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::BackupSource::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> +pub fn radroots_storage::BackupSource::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::BackupSource::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> pub fn radroots_storage::BackupSource::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::BackupSource::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::BackupSource::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> +pub fn radroots_storage::BackupSource::verify_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> impl radroots_storage::backup::StorageReliability for radroots_storage::memory::MemoryStorage pub fn radroots_storage::memory::MemoryStorage::begin_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::begin_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::verify_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub trait radroots_storage::EventStore: core::marker::Send + core::marker::Sync pub fn radroots_storage::EventStore::admit(&self, radroots_storage::event::EventAdmission) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::event::AdmissionReceipt, radroots_storage::Error>> pub fn radroots_storage::EventStore::query_provenance(&self, radroots_event::id::EventId, radroots_storage::event::EventQueryBounds) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::event::EventPage<radroots_storage::event::StoredEventProvenance>, radroots_storage::Error>> diff --git a/contracts/api_baselines/radroots_storage_sqlite.txt b/contracts/api_baselines/radroots_storage_sqlite.txt @@ -566,11 +566,14 @@ pub fn radroots_storage_sqlite::SqliteStorage::query_authored_drafts(&self, radr impl radroots_storage::backup::StorageReliability for radroots_storage_sqlite::SqliteStorage pub fn radroots_storage_sqlite::SqliteStorage::begin_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::begin_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::error::Error>> +pub fn radroots_storage_sqlite::SqliteStorage::capture_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::capability::BackupCapabilityError>> pub fn radroots_storage_sqlite::SqliteStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::error::Error>> +pub fn radroots_storage_sqlite::SqliteStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError>> pub fn radroots_storage_sqlite::SqliteStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::error::Error>> +pub fn radroots_storage_sqlite::SqliteStorage::verify_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError>> impl radroots_storage::event::EventStore for radroots_storage_sqlite::SqliteStorage pub fn radroots_storage_sqlite::SqliteStorage::admit(&self, radroots_storage::event::EventAdmission) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::event::AdmissionReceipt, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::query_provenance(&self, radroots_event::id::EventId, radroots_storage::event::EventQueryBounds) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::event::EventPage<radroots_storage::event::StoredEventProvenance>, radroots_storage::error::Error>> diff --git a/contracts/architecture/decisions/storage_backup_capability.v1.json b/contracts/architecture/decisions/storage_backup_capability.v1.json @@ -0,0 +1,12 @@ +{ + "schema": "radroots.storage-backup-capability.v1", + "status": "approved", + "scope": "Existing canonical owner capture, verification and finalization through StorageReliability and SDK Operations", + "source_boundary": "Only the SQLite owner performs snapshots and filesystem publication. The SPI exposes no filesystem paths, SQL or backend handles.", + "unsupported_backends": "Actual operations fail closed. Reliability metadata transitions do not prove that any snapshot exists.", + "compatibility": "Existing backup manifests, formats, member names, schemas and concrete-owner operations remain unchanged. Default unsupported methods preserve other backend implementations.", + "error_boundary": "Typed bounded errors omit filesystem paths, raw database details and nested I/O sources.", + "consistency": "The capability preserves existing per-member snapshot semantics. Application and media coordination, identity binding and cross-member consistency require explicit host orchestration; no global transaction is implied.", + "lifecycle": "Unpolled operations have no effects. Owner close refuses later capture, verification and finalization. Failed or interrupted capture may retain staging for explicit reconciliation.", + "excluded": ["application backup formats", "automatic recovery", "restore orchestration", "secret export consent", "new filesystem owner", "coverage exclusions"] +} diff --git a/crates/sdk/README.md b/crates/sdk/README.md @@ -92,6 +92,14 @@ and `ClientBuilder::sqlite(...)` are explicit constructors; merely enabling a feature or constructing an empty builder creates no resource. Signers, event sources, event sinks, and the sync engine are injected separately. +`client.storage_operations()` exposes actual owner backup capture, verification, +and finalization through the existing storage SPI. SQLite requires an explicit +host-owned backup root in its open options. Unsupported backends refuse these +operations even if they support reliability metadata. Related application +state, identity binding, media leases, and export consent remain host concerns; +these operations expose no paths and do not create an application-wide snapshot +transaction. Failed capture may retain staging for explicit reconciliation. + Native mobile hosts can retain one client while changing host-owned identity and relay selection. The host injects one opaque implementation of the canonical `radroots_signing::Signer` SPI; the SDK has no mutable secret slot and diff --git a/crates/sdk/src/storage.rs b/crates/sdk/src/storage.rs @@ -19,8 +19,8 @@ pub type SqliteOpenMode = radroots_storage_sqlite::OpenMode; pub type SqlitePaths = radroots_storage_sqlite::Paths; use radroots_storage::backup::{ - BackupId, BackupOperation, BackupPlan, BackupTransition, ReliabilityRevision, RestoreOperation, - RestorePlan, RestoreTransition, StorageReliability, + BackupCapabilityError, BackupId, BackupManifest, BackupOperation, BackupPlan, BackupTransition, + ReliabilityRevision, RestoreOperation, RestorePlan, RestoreTransition, StorageReliability, }; /// Borrowed reliability operations over the canonical backend-neutral SPI. @@ -34,6 +34,34 @@ impl<'a> Operations<'a> { Self { storage } } + /// Captures actual members through the canonical owner. Related application + /// state and media still require host coordination; no metadata transition + /// is accepted as evidence of a snapshot. + pub async fn capture_backup( + &self, + plan: BackupPlan, + ) -> Result<BackupManifest, BackupCapabilityError> { + StorageReliability::capture_backup(self.storage, plan).await + } + + /// Verifies the owner's exact staged bundle without finalizing it. + pub async fn verify_backup( + &self, + plan: BackupPlan, + manifest: BackupManifest, + ) -> Result<(), BackupCapabilityError> { + StorageReliability::verify_backup(self.storage, plan, manifest).await + } + + /// Verifies and finalizes the owner's bundle, retaining its opaque identity. + pub async fn finalize_backup( + &self, + plan: BackupPlan, + manifest: BackupManifest, + ) -> Result<(), BackupCapabilityError> { + StorageReliability::finalize_backup(self.storage, plan, manifest).await + } + /// Begins or resumes one idempotent backup plan. pub async fn begin_backup( &self, @@ -106,6 +134,9 @@ impl std::fmt::Debug for Operations<'_> { } } +#[cfg(all(test, any(feature = "memory", feature = "sqlite")))] +mod backup_tests; + #[cfg(all(test, feature = "memory"))] mod tests { use std::sync::Arc; diff --git a/crates/sdk/src/storage/backup_tests.rs b/crates/sdk/src/storage/backup_tests.rs @@ -0,0 +1,141 @@ +use super::*; +use radroots_storage::backup::{BackupFormatVersion, BackupSecretPolicy}; + +fn plan() -> BackupPlan { + BackupPlan::new( + BackupId::new([6; 16]).unwrap(), + BackupFormatVersion::V1, + BackupSecretPolicy::IncludeProtectedStorage, + 100, + ) + .unwrap() +} + +#[cfg(feature = "memory")] +#[tokio::test] +async fn reliability_metadata_never_substitutes_for_actual_backup_capability() { + use radroots_storage::backup::{BackupMember, BackupMemberKind, MemberDigest}; + let client = crate::ClientBuilder::memory_default().build().unwrap(); + let operations = client.storage_operations().unwrap(); + let plan = plan(); + operations.begin_backup(plan.clone()).await.unwrap(); + let manifest = BackupManifest::new( + plan.format_version(), + plan.backup_id(), + plan.requested_at_unix_ms(), + plan.secret_policy(), + vec![ + BackupMember::new( + "runtime/events.bin", + BackupMemberKind::Runtime, + 1, + MemberDigest::new([8; 32]), + ) + .unwrap(), + ], + ) + .unwrap(); + assert_eq!( + operations.capture_backup(plan.clone()).await, + Err(BackupCapabilityError::Unsupported) + ); + assert_eq!( + operations + .verify_backup(plan.clone(), manifest.clone()) + .await, + Err(BackupCapabilityError::Unsupported) + ); + assert_eq!( + operations.finalize_backup(plan, manifest).await, + Err(BackupCapabilityError::Unsupported) + ); + client.close().await.unwrap(); +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sdk_delegates_real_backup_and_close_to_one_canonical_owner() { + use radroots_storage::{ + authored_draft::{AuthoredDraft, AuthoredDraftId, AuthoredDraftStage}, + event::SourceGeneration, + }; + let root = tempfile::tempdir().unwrap(); + let backup = tempfile::tempdir().unwrap(); + let options = SqliteOptions::new( + SqlitePaths::from_directory(root.path()).unwrap(), + SqliteOpenMode::Create, + ) + .with_source_generation(SourceGeneration::new([9; 32]).unwrap(), 100) + .unwrap() + .with_backup_root(backup.path()) + .unwrap(); + let client = crate::ClientBuilder::sqlite(options) + .await + .unwrap() + .build() + .unwrap(); + let draft = AuthoredDraft::initial( + AuthoredDraftId::new([7; 16]).unwrap(), + [3; 32], + "fixture.backup.v1", + b"retained authored work".to_vec(), + AuthoredDraftStage::Draft, + None, + 100, + ) + .unwrap(); + client + .storage() + .unwrap() + .append_authored_draft(draft.clone(), None) + .await + .unwrap(); + let operations = client.storage_operations().unwrap(); + let plan = plan(); + drop(operations.capture_backup(plan.clone())); + assert_eq!(std::fs::read_dir(backup.path()).unwrap().count(), 0); + let manifest = operations.capture_backup(plan.clone()).await.unwrap(); + assert_eq!(manifest.backup_id(), plan.backup_id()); + assert_eq!(manifest.members().len(), 2); + assert!( + manifest + .members() + .iter() + .all(|member| member.byte_length() > 0) + ); + operations + .verify_backup(plan.clone(), manifest.clone()) + .await + .unwrap(); + for _ in 0..2 { + operations + .finalize_backup(plan.clone(), manifest.clone()) + .await + .unwrap(); + } + assert_eq!(std::fs::read_dir(backup.path()).unwrap().count(), 1); + assert_eq!( + client + .storage() + .unwrap() + .authored_draft_head(draft.draft_id()) + .await + .unwrap(), + Some(draft) + ); + client.close().await.unwrap(); + assert_eq!( + operations.capture_backup(plan.clone()).await, + Err(BackupCapabilityError::Unavailable) + ); + assert_eq!( + operations + .verify_backup(plan.clone(), manifest.clone()) + .await, + Err(BackupCapabilityError::Unavailable) + ); + assert_eq!( + operations.finalize_backup(plan, manifest).await, + Err(BackupCapabilityError::Unavailable) + ); +} diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -172,6 +172,7 @@ fn package_contains_only_reachable_sources_and_registered_targets() { "listing/operational_listing/validation.rs".to_owned(), "signing.rs".to_owned(), "storage.rs".to_owned(), + "storage/backup_tests.rs".to_owned(), "sync.rs".to_owned(), "sync/tests/selected.rs".to_owned(), "trade.rs".to_owned(), diff --git a/crates/storage/README.md b/crates/storage/README.md @@ -75,11 +75,23 @@ Their methods return boxed `Future + Send` values, allowing the host to choose the async executor. Implementations must not install an executor, spawn hidden workers, read a clock, generate identities, or perform implicit retries. +`BackupSource::capture_backup`, `verify_backup`, and `finalize_backup` invoke +actual owner operations. Their default implementation returns typed +`BackupCapabilityError::Unsupported`; reliability metadata transitions cannot +substitute for a snapshot. The boundary returns manifests and completion, never +paths or database handles. Hosts coordinate application state and referenced +files separately; per-member snapshots do not imply a global transaction. + +Actual backup capture can retain incomplete staging after interruption. It +does not replace live data or report that staging as finalized. The host must +retain the original plan and manifest and reconcile an ambiguous result before +retrying; cancellation never authorizes deleting retained evidence. + ## Cancellation and commit points Dropping a returned future requests cancellation. Read operations may stop -without side effects. Mutating operations must document and preserve their -local durable commit point: +without side effects. Transactional record operations preserve their local +durable commit point: - before the commit point, cancellation or failure leaves no partial state; - after a successful commit, cancellation cannot claim rollback; diff --git a/crates/storage/src/backup.rs b/crates/storage/src/backup.rs @@ -8,6 +8,9 @@ use crate::{ status::{IntegrityStatus, StorageStatus}, }; +mod capability; +pub use capability::BackupCapabilityError; + pub const BACKUP_MEMBER_PATH_MAX_BYTES: usize = 512; pub const BACKUP_MEMBER_MAX: usize = 1_024; @@ -685,6 +688,35 @@ pub enum RestoreTransition { /// Backend-neutral reliability operations. Implementations own staging and /// atomic filesystem replacement; callers receive only typed state. pub trait StorageReliability: Send + Sync { + /// Captures actual owner-produced members. Metadata transitions alone do + /// not implement this capability. Hosts must separately coordinate related + /// application state and files; a member snapshot is not a global transaction. + fn capture_backup( + &self, + _plan: BackupPlan, + ) -> BoxFuture<'_, Result<BackupManifest, BackupCapabilityError>> { + Box::pin(async { Err(BackupCapabilityError::Unsupported) }) + } + + /// Verifies the exact staged members against the supplied plan and manifest. + fn verify_backup( + &self, + _plan: BackupPlan, + _manifest: BackupManifest, + ) -> BoxFuture<'_, Result<(), BackupCapabilityError>> { + Box::pin(async { Err(BackupCapabilityError::Unsupported) }) + } + + /// Verifies and finalizes the exact owner-managed bundle. No filesystem + /// pathname or database handle crosses this boundary. + fn finalize_backup( + &self, + _plan: BackupPlan, + _manifest: BackupManifest, + ) -> BoxFuture<'_, Result<(), BackupCapabilityError>> { + Box::pin(async { Err(BackupCapabilityError::Unsupported) }) + } + fn begin_backup(&self, plan: BackupPlan) -> BoxFuture<'_, Result<BackupOperation, Error>>; fn transition_backup( &self, diff --git a/crates/storage/src/backup/capability.rs b/crates/storage/src/backup/capability.rs @@ -0,0 +1,61 @@ +use core::fmt; + +/// Path-free failures from actual owner backup operations. These are distinct +/// from the optimistic metadata-transition errors of reliability records. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum BackupCapabilityError { + /// This backend provides no actual snapshot capability. + Unsupported, + /// The owner is closed or its storage is unavailable. + Unavailable, + /// An explicit valid host-owned backup location is required. + InvalidConfiguration, + /// The requested backup format is not supported by the owner. + UnsupportedVersion, + /// Existing staged or finalized state requires reconciliation. + Conflict, + /// The supplied plan, inventory or member bytes could not be verified. + VerificationFailed, + /// Capture or durable filesystem publication did not complete successfully. + Failed, +} + +impl fmt::Display for BackupCapabilityError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Unsupported => "storage backup capability is unsupported", + Self::Unavailable => "storage backup owner is unavailable", + Self::InvalidConfiguration => "storage backup configuration is invalid", + Self::UnsupportedVersion => "storage backup format is unsupported", + Self::Conflict => "storage backup state requires reconciliation", + Self::VerificationFailed => "storage backup verification failed", + Self::Failed => "storage backup did not complete", + }) + } +} + +impl std::error::Error for BackupCapabilityError {} + +#[cfg(test)] +mod tests { + use super::BackupCapabilityError as E; + + #[test] + fn errors_are_bounded_public_reports_without_paths_or_backend_sources() { + for error in [ + E::Unsupported, + E::Unavailable, + E::InvalidConfiguration, + E::UnsupportedVersion, + E::Conflict, + E::VerificationFailed, + E::Failed, + ] { + let report = error.to_string(); + assert!(report.len() < 80); + assert!(!report.contains('/')); + assert!(std::error::Error::source(&error).is_none()); + } + } +} diff --git a/crates/storage_sqlite/src/backup.rs b/crates/storage_sqlite/src/backup.rs @@ -8,10 +8,10 @@ use std::{ }; use radroots_storage::backup::{ - BackupFormatVersion, BackupId, BackupManifest, BackupMember, BackupMemberKind, BackupOperation, - BackupPlan, BackupSecretPolicy, BackupTransition, MemberDigest, MemberVerification, - ReliabilityRevision, RestoreMemberStatus, RestoreOperation, RestorePlan, RestoreTransition, - StorageReliability, + BackupCapabilityError, BackupFormatVersion, BackupId, BackupManifest, BackupMember, + BackupMemberKind, BackupOperation, BackupPlan, BackupSecretPolicy, BackupTransition, + MemberDigest, MemberVerification, ReliabilityRevision, RestoreMemberStatus, RestoreOperation, + RestorePlan, RestoreTransition, StorageReliability, }; use radroots_storage::status::EventStoreMode; use radroots_storage::{Error as StorageError, outbox::BoxFuture}; @@ -28,6 +28,10 @@ const PRIVATE_MEMBER: &str = "private/private.sqlite"; const RESTORE_MARKER_MAGIC: &[u8; 8] = b"RDRSTR01"; const RESTORE_MARKER_BYTES: usize = 105; +mod capability; +#[cfg(test)] +mod capability_tests; + #[derive(Default)] pub(crate) struct ReliabilityState { backups: Vec<BackupOperation>, @@ -46,6 +50,42 @@ impl SqliteStorage { } impl StorageReliability for SqliteStorage { + fn capture_backup( + &self, + plan: BackupPlan, + ) -> BoxFuture<'_, Result<BackupManifest, BackupCapabilityError>> { + Box::pin(async move { + SqliteStorage::capture_backup(self, &plan) + .await + .map_err(capability::map_error) + }) + } + + fn verify_backup( + &self, + plan: BackupPlan, + manifest: BackupManifest, + ) -> BoxFuture<'_, Result<(), BackupCapabilityError>> { + Box::pin(async move { + SqliteStorage::verify_backup(self, &plan, &manifest) + .await + .map_err(capability::map_error) + }) + } + + fn finalize_backup( + &self, + plan: BackupPlan, + manifest: BackupManifest, + ) -> BoxFuture<'_, Result<(), BackupCapabilityError>> { + Box::pin(async move { + SqliteStorage::finalize_backup(self, &plan, &manifest) + .await + .map(|_| ()) + .map_err(capability::map_error) + }) + } + fn begin_backup( &self, plan: BackupPlan, diff --git a/crates/storage_sqlite/src/backup/capability.rs b/crates/storage_sqlite/src/backup/capability.rs @@ -0,0 +1,17 @@ +use radroots_storage::backup::BackupCapabilityError; + +pub(super) fn map_error(error: crate::Error) -> BackupCapabilityError { + use crate::Error as E; + match error { + E::BackupBackendUnavailable => BackupCapabilityError::Unavailable, + E::BackupRootRequired | E::InvalidBackupRoot(_) => { + BackupCapabilityError::InvalidConfiguration + } + E::UnsupportedBackupVersion => BackupCapabilityError::UnsupportedVersion, + E::BackupBundleAlreadyExists(_) => BackupCapabilityError::Conflict, + E::BackupBundleMissing(_) + | E::BackupVerificationFailed { .. } + | E::BackupUnexpectedEntry(_) => BackupCapabilityError::VerificationFailed, + _ => BackupCapabilityError::Failed, + } +} diff --git a/crates/storage_sqlite/src/backup/capability_tests.rs b/crates/storage_sqlite/src/backup/capability_tests.rs @@ -0,0 +1,218 @@ +use super::*; +use crate::OpenOptions; +use radroots_storage::authored_draft::{ + AuthoredDraft, AuthoredDraftId, AuthoredDraftStage, AuthoredDraftStore, +}; +use radroots_storage::event::SourceGeneration; + +fn plan(id: u8) -> BackupPlan { + BackupPlan::new( + BackupId::new([id; 16]).unwrap(), + BackupFormatVersion::V1, + BackupSecretPolicy::IncludeProtectedStorage, + 100, + ) + .unwrap() +} + +async fn fixture(root: &Path, backup: &Path) -> SqliteStorage { + SqliteStorage::open( + OpenOptions::new( + crate::Paths::from_directory(root).unwrap(), + OpenMode::Create, + ) + .with_source_generation(SourceGeneration::new([7; 32]).unwrap(), 100) + .unwrap() + .with_backup_root(backup) + .unwrap(), + ) + .await + .unwrap() +} + +#[tokio::test] +async fn canonical_capability_captures_wal_and_finalizes_exact_members() { + let root = tempfile::tempdir().unwrap(); + let backup = tempfile::tempdir().unwrap(); + let store = fixture(root.path(), backup.path()).await; + let draft = AuthoredDraft::initial( + AuthoredDraftId::new([8; 16]).unwrap(), + [3; 32], + "fixture.backup.v1", + b"latest committed WAL work".to_vec(), + AuthoredDraftStage::Draft, + None, + 7777, + ) + .unwrap(); + store + .append_authored_draft(draft.clone(), None) + .await + .unwrap(); + assert!( + fs::metadata(root.path().join("runtime.sqlite-wal")) + .unwrap() + .len() + > 0 + ); + let plan = plan(1); + let owner: &dyn StorageReliability = &store; + drop(owner.capture_backup(plan.clone())); + assert_eq!(fs::read_dir(backup.path()).unwrap().count(), 0); + let manifest = owner.capture_backup(plan.clone()).await.unwrap(); + assert_eq!(manifest.members().len(), 2); + let layout = BackupLayout::new(backup.path(), &plan); + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&layout.runtime_file) + .read_only(true), + ) + .await + .unwrap(); + let value: Vec<u8> = + sqlx::query_scalar("SELECT snapshot FROM radroots_runtime_authored_draft_revisions") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!( + serde_json::from_slice::<AuthoredDraft>(&value).unwrap(), + draft + ); + connection.close().await.unwrap(); + owner + .verify_backup(plan.clone(), manifest.clone()) + .await + .unwrap(); + for _ in 0..2 { + owner + .finalize_backup(plan.clone(), manifest.clone()) + .await + .unwrap(); + } + assert!(!layout.staging.exists()); + assert!(layout.finalized.is_dir()); + assert_eq!( + owner.capture_backup(plan.clone()).await, + Err(BackupCapabilityError::Conflict) + ); + store.close().await.unwrap(); + assert_eq!( + owner.capture_backup(plan.clone()).await, + Err(BackupCapabilityError::Unavailable) + ); + assert_eq!( + owner.verify_backup(plan.clone(), manifest.clone()).await, + Err(BackupCapabilityError::Unavailable) + ); + assert_eq!( + owner.finalize_backup(plan, manifest).await, + Err(BackupCapabilityError::Unavailable) + ); +} + +#[tokio::test] +async fn canonical_capability_refuses_tampered_inventory_and_retains_staging() { + let root = tempfile::tempdir().unwrap(); + let backup = tempfile::tempdir().unwrap(); + let store = fixture(root.path(), backup.path()).await; + let owner: &dyn StorageReliability = &store; + let plan = plan(2); + let manifest = owner.capture_backup(plan.clone()).await.unwrap(); + let layout = BackupLayout::new(backup.path(), &plan); + let bytes = fs::read(&layout.runtime_file).unwrap(); + for bad_size in [false, true] { + let mut members = manifest.members().to_vec(); + let original = &members[0]; + members[0] = BackupMember::new( + original.relative_path(), + original.kind(), + original.byte_length() + u64::from(bad_size), + if bad_size { + original.sha256() + } else { + MemberDigest::new([0; 32]) + }, + ) + .unwrap(); + let bad = BackupManifest::new( + manifest.format_version(), + manifest.backup_id(), + manifest.created_at_unix_ms(), + manifest.secret_policy(), + members, + ) + .unwrap(); + assert_eq!( + owner.verify_backup(plan.clone(), bad.clone()).await, + Err(BackupCapabilityError::VerificationFailed) + ); + assert_eq!( + owner.finalize_backup(plan.clone(), bad).await, + Err(BackupCapabilityError::VerificationFailed) + ); + } + let wrong_plan = BackupPlan::new( + plan.backup_id(), + plan.format_version(), + plan.secret_policy(), + 101, + ) + .unwrap(); + assert_eq!( + owner.verify_backup(wrong_plan, manifest.clone()).await, + Err(BackupCapabilityError::VerificationFailed) + ); + fs::write(layout.staging.join("unexpected"), b"retained").unwrap(); + assert_eq!( + owner.verify_backup(plan.clone(), manifest.clone()).await, + Err(BackupCapabilityError::VerificationFailed) + ); + assert_eq!(fs::read(&layout.runtime_file).unwrap(), bytes); + assert!(!layout.finalized.exists()); + assert_eq!( + owner.capture_backup(plan.clone()).await, + Err(BackupCapabilityError::Conflict) + ); + let missing = self::plan(3); + assert_eq!( + owner.verify_backup(missing, manifest).await, + Err(BackupCapabilityError::VerificationFailed) + ); + store.close().await.unwrap(); +} + +#[tokio::test] +async fn canonical_capability_classifies_configuration_version_and_io_without_paths() { + let root = tempfile::tempdir().unwrap(); + let backup = tempfile::tempdir().unwrap(); + let mut store = fixture(root.path(), backup.path()).await; + let future = BackupPlan::new( + BackupId::new([4; 16]).unwrap(), + BackupFormatVersion::new(2).unwrap(), + BackupSecretPolicy::IncludeProtectedStorage, + 100, + ) + .unwrap(); + assert_eq!( + StorageReliability::capture_backup(&store, future).await, + Err(BackupCapabilityError::UnsupportedVersion) + ); + store.backup_root = None; + assert_eq!( + StorageReliability::capture_backup(&store, plan(4)).await, + Err(BackupCapabilityError::InvalidConfiguration) + ); + store.backup_root = Some(std::sync::Arc::new(backup.path().join("absent"))); + assert_eq!( + StorageReliability::capture_backup(&store, plan(4)).await, + Err(BackupCapabilityError::InvalidConfiguration) + ); + // The public SPI must never forward backend paths or nested I/O details. + let error = capability::map_error(Error::BackupFilesystem { + operation: "capture /private/canary", + source: std::io::Error::other("credential-canary"), + }); + assert_eq!(error, BackupCapabilityError::Failed); + assert!(!format!("{error:?} {error}").contains("canary")); + store.close().await.unwrap(); +}