commit b19555ab50f80899c51bd3ed9ca5bdc2b5d8227d
parent 4eb02491b931713ba07784cb51e239006396ed4f
Author: triesap <tyson@radroots.org>
Date: Wed, 26 Aug 2026 03:19:37 +0000
feat(storage): add governed backup restore
- wrap Lib capture and verification with sealed Harvest capabilities
- stage and finalize identity-bound restores through the recovery protocol
- preserve legacy state and reopen verified restored state
- freeze the public boundary and round-trip qualification
Diffstat:
9 files changed, 491 insertions(+), 21 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -99,6 +99,10 @@ substitute.
- Recovery and backup operations must be explicit, user-initiated, bounded,
fail closed, and keep sensitive material out of logs, crash text, analytics,
filenames, and long-lived UI state.
+- Backup and restore must use the sealed HarvestCircle wrappers over Lib's
+ capture, verify, stage, finalize, and marker-recovery protocol. Verification
+ requires a trusted manifest digest, current database identity, and positive
+ caller-supplied member limit; never accept an arbitrary replacement path.
- Clipboard writes of sensitive output require explicit user action, bounded
lifetime, ownership-aware clearing, and tests for cancellation and
replacement. Never clear unrelated clipboard content.
diff --git a/README.md b/README.md
@@ -55,6 +55,13 @@ close, backup, and restore mechanics. The historical `harvestcircle.sqlite3`
file is legacy evidence only and is never imported, repaired, deleted, or
treated as current state.
+Online backup capture returns the canonical manifest in memory and writes only
+the governed `state.sqlite` member into a caller-selected new directory.
+Restore accepts only a digest-bound, identity-bound, size-bounded verified
+backup capability, closes the live host, uses the governed marker protocol,
+and reopens recovered state before returning. There is no arbitrary database
+repair or pathname-only restore authority.
+
## Project documentation
The consuming Radroots monorepo owns normative HarvestCircle specifications,
diff --git a/core/compatibility/harvestcircle-storage-api-v1.txt b/core/compatibility/harvestcircle-storage-api-v1.txt
@@ -9,6 +9,9 @@ impl core::fmt::Display for harvestcircle_storage::HarvestCircleStorageContractE
pub fn harvestcircle_storage::HarvestCircleStorageContractError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct harvestcircle_storage::Database
impl harvestcircle_storage::Database
+pub async fn harvestcircle_storage::Database::capture_online_backup(&self, &std::path::Path, radroots_service_sqlite::backup::manifest::BackupCreatedAtUnixMs) -> core::result::Result<radroots_service_sqlite::backup::manifest::ServiceBackupManifest, harvestcircle_domain::error::SafeError>
+pub async fn harvestcircle_storage::Database::restore_verified_backup(&mut self, &radroots_runtime_paths::context::RuntimeContext, harvestcircle_storage::VerifiedHarvestCircleBackup, u64, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<(), harvestcircle_domain::error::SafeError>
+impl harvestcircle_storage::Database
pub async fn harvestcircle_storage::Database::close(&self) -> core::result::Result<(), harvestcircle_domain::error::SafeError>
pub const fn harvestcircle_storage::Database::metadata(&self) -> &radroots_service_sqlite::metadata::ServiceDatabaseMetadata
pub async fn harvestcircle_storage::Database::open(&radroots_runtime_paths::context::RuntimeContext, u64, u64, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<Self, harvestcircle_domain::error::SafeError>
@@ -55,6 +58,9 @@ pub fn harvestcircle_storage::OsKeyringSecretStore::contains(&self, harvestcircl
pub fn harvestcircle_storage::OsKeyringSecretStore::delete(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<(), harvestcircle_domain::error::SafeError>
pub fn harvestcircle_storage::OsKeyringSecretStore::load(&self, harvestcircle_domain::key::PublicKey) -> core::result::Result<harvestcircle_domain::key::SecretKeyInput, harvestcircle_domain::error::SafeError>
pub fn harvestcircle_storage::OsKeyringSecretStore::put(&self, harvestcircle_domain::key::PublicKey, harvestcircle_domain::key::SecretKeyInput) -> core::result::Result<(), harvestcircle_domain::error::SafeError>
+pub struct harvestcircle_storage::VerifiedHarvestCircleBackup
+impl core::fmt::Debug for harvestcircle_storage::VerifiedHarvestCircleBackup
+pub fn harvestcircle_storage::VerifiedHarvestCircleBackup::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub const harvestcircle_storage::CREDENTIAL_SERVICE: &str
pub const harvestcircle_storage::CURRENT_SCHEMA_VERSION: u32
pub const harvestcircle_storage::HARVESTCIRCLE_ACTOR_MAILBOX_CAPACITY: usize
@@ -74,3 +80,4 @@ pub const harvestcircle_storage::HARVESTCIRCLE_STATE_SCHEMA_VERSION: u32
pub const harvestcircle_storage::HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY: usize
pub fn harvestcircle_storage::harvestcircle_migration_catalog() -> core::result::Result<radroots_service_sqlite::migration::MigrationCatalog, harvestcircle_storage::HarvestCircleStorageContractError>
pub fn harvestcircle_storage::harvestcircle_schema_catalog() -> core::result::Result<radroots_service_sqlite::integrity::catalog::SchemaCatalog, harvestcircle_storage::HarvestCircleStorageContractError>
+pub fn harvestcircle_storage::verify_harvestcircle_backup(&[u8], radroots_service_sqlite::backup::manifest::BackupManifestSha256, &std::path::Path, &radroots_service_sqlite::metadata::ServiceDatabaseIdentity, core::num::nonzero::NonZeroU64) -> core::result::Result<harvestcircle_storage::VerifiedHarvestCircleBackup, harvestcircle_domain::error::SafeError>
diff --git a/core/crates/harvestcircle_storage/src/backup.rs b/core/crates/harvestcircle_storage/src/backup.rs
@@ -0,0 +1,148 @@
+use core::{fmt, num::NonZeroU64};
+use std::path::Path;
+
+use radroots_runtime_paths::RuntimeContext;
+use radroots_service_sqlite::{
+ BackupCreatedAtUnixMs, BackupManifestSha256, MigrationAppliedAtUnixSeconds,
+ MigrationBuildIdentity, ServiceBackupManifest, ServiceDatabaseIdentity, VerifiedServiceBackup,
+ finalize_staged_restore, stage_verified_restore, verify_backup_bundle,
+};
+
+use crate::db::{invalid_storage_contract, map_service_error};
+use crate::{Database, HarvestCircleStorageContract};
+use harvestcircle_domain::{SafeError, SafeErrorCode, SafeMessage};
+
+/// Retained, non-forgeable proof of one identity-bound HarvestCircle backup.
+///
+/// This type intentionally exposes no path, descriptor, or raw database handle.
+/// It is single-use restore authority and cannot be cloned:
+///
+/// ```compile_fail
+/// use harvestcircle_storage::VerifiedHarvestCircleBackup;
+///
+/// fn require_clone<T: Clone>() {}
+/// require_clone::<VerifiedHarvestCircleBackup>();
+/// ```
+pub struct VerifiedHarvestCircleBackup {
+ inner: VerifiedServiceBackup,
+}
+
+impl fmt::Debug for VerifiedHarvestCircleBackup {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("VerifiedHarvestCircleBackup([redacted])")
+ }
+}
+
+/// Verifies one untrusted canonical backup bundle without mutating it.
+///
+/// The expected digest, database identity, and positive member limit are
+/// trusted inputs. This operation is synchronous and performs bounded file and
+/// SQLite reads; callers own any supervised worker and deadline.
+pub fn verify_harvestcircle_backup(
+ manifest_bytes: &[u8],
+ expected_manifest_digest: BackupManifestSha256,
+ bundle_directory: &Path,
+ expected_identity: &ServiceDatabaseIdentity,
+ maximum_state_bytes: NonZeroU64,
+) -> Result<VerifiedHarvestCircleBackup, SafeError> {
+ verify_backup_bundle(
+ manifest_bytes,
+ expected_manifest_digest,
+ bundle_directory,
+ expected_identity,
+ maximum_state_bytes,
+ )
+ .map(|inner| VerifiedHarvestCircleBackup { inner })
+ .map_err(|_| invalid_backup())
+}
+
+impl Database {
+ /// Captures one point-in-time backup into a caller-selected new directory.
+ pub async fn capture_online_backup(
+ &self,
+ staging_directory: &Path,
+ created_at_unix_ms: BackupCreatedAtUnixMs,
+ ) -> Result<ServiceBackupManifest, SafeError> {
+ self.host()
+ .capture_online_backup(staging_directory, created_at_unix_ms)
+ .await
+ .map_err(map_service_error)
+ }
+
+ /// Closes live state, installs one verified backup, and reopens recovered state.
+ ///
+ /// Exclusive mutable access prevents concurrent use of the pre-restore
+ /// host. Preflight failure leaves that host open and usable. Failure after
+ /// close may leave exact recovery evidence; a later ordinary
+ /// `Database::open` reconciles that evidence.
+ pub async fn restore_verified_backup(
+ &mut self,
+ context: &RuntimeContext,
+ verified: VerifiedHarvestCircleBackup,
+ applied_at_unix_s: u64,
+ build: &MigrationBuildIdentity,
+ ) -> Result<(), SafeError> {
+ let contract = HarvestCircleStorageContract::from_runtime_context(context)
+ .map_err(|_| invalid_storage_contract())?;
+ let expected = self.metadata().identity();
+ let backup_metadata = verified.inner.database_metadata();
+ if expected.service() != contract.paths().service()
+ || expected.instance() != contract.paths().instance()
+ || expected.supported_state_schema_version() != contract.state_schema_version()
+ || expected.application_id() != contract.application_id()
+ || backup_metadata.service() != expected.service()
+ || backup_metadata.instance() != expected.instance()
+ || backup_metadata.source_generation() != expected.source_generation()
+ || backup_metadata.application_id() != expected.application_id()
+ || backup_metadata.state_schema_version() > expected.supported_state_schema_version()
+ {
+ return Err(invalid_storage_contract());
+ }
+ let applied_at = MigrationAppliedAtUnixSeconds::new(applied_at_unix_s)
+ .map_err(|_| invalid_storage_contract())?;
+
+ self.close().await?;
+ let staged = stage_verified_restore(
+ contract.paths(),
+ &expected,
+ contract.migrations(),
+ contract.schema(),
+ verified.inner,
+ )
+ .await
+ .map_err(map_restore_error)?;
+ finalize_staged_restore(staged)
+ .await
+ .map_err(map_restore_error)?;
+ let reopened = Self::open_existing(&contract, applied_at, build).await?;
+ *self = reopened;
+ Ok(())
+ }
+}
+
+const fn invalid_backup() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::StorageBackupInvalid,
+ SafeMessage::new("The selected backup could not be verified."),
+ )
+}
+
+fn map_restore_error(error: radroots_service_sqlite::ServiceSqliteError) -> SafeError {
+ use radroots_service_sqlite::ServiceSqliteErrorKind;
+
+ match error.kind() {
+ ServiceSqliteErrorKind::Metadata
+ | ServiceSqliteErrorKind::Migration
+ | ServiceSqliteErrorKind::Integrity
+ | ServiceSqliteErrorKind::Backup => invalid_backup(),
+ ServiceSqliteErrorKind::Recovery => SafeError::new(
+ SafeErrorCode::StorageQuarantined,
+ SafeMessage::new("The application state requires recovery."),
+ ),
+ ServiceSqliteErrorKind::Authority
+ | ServiceSqliteErrorKind::Open
+ | ServiceSqliteErrorKind::Create
+ | ServiceSqliteErrorKind::Pragma
+ | ServiceSqliteErrorKind::Restore => map_service_error(error),
+ }
+}
diff --git a/core/crates/harvestcircle_storage/src/db.rs b/core/crates/harvestcircle_storage/src/db.rs
@@ -52,25 +52,7 @@ impl Database {
.try_exists()
.map_err(|_| storage_unavailable())?
{
- let intent = ExistingServiceDatabaseIntent::new(
- contract.paths(),
- contract.state_schema_version(),
- contract.application_id(),
- );
- let (opened, _) = ServiceSqliteHost::open_read_write_existing_with_intent(
- contract.paths(),
- &intent,
- contract.migrations(),
- contract.schema(),
- options,
- applied_at,
- build,
- &[],
- )
- .await
- .map_err(map_service_error)?;
- let (host, metadata) = opened.into_parts();
- return Ok(Self { host, metadata });
+ return Self::open_existing(&contract, applied_at, build).await;
}
let mut generation = [0_u8; 32];
@@ -122,6 +104,32 @@ impl Database {
pub(crate) const fn host(&self) -> &ServiceSqliteHost {
&self.host
}
+
+ pub(crate) async fn open_existing(
+ contract: &HarvestCircleStorageContract,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+ ) -> Result<Self, SafeError> {
+ let intent = ExistingServiceDatabaseIntent::new(
+ contract.paths(),
+ contract.state_schema_version(),
+ contract.application_id(),
+ );
+ let (opened, _) = ServiceSqliteHost::open_read_write_existing_with_intent(
+ contract.paths(),
+ &intent,
+ contract.migrations(),
+ contract.schema(),
+ ServiceSqliteConnectionOptions::reviewed(),
+ applied_at,
+ build,
+ &[],
+ )
+ .await
+ .map_err(map_service_error)?;
+ let (host, metadata) = opened.into_parts();
+ Ok(Self { host, metadata })
+ }
}
async fn initialize_application_schema(path: std::path::PathBuf) -> Result<(), sqlx::Error> {
@@ -166,7 +174,7 @@ pub(crate) fn map_transaction_error(error: ServiceSqliteTransactionError<SafeErr
}
}
-fn map_service_error(error: radroots_service_sqlite::ServiceSqliteError) -> SafeError {
+pub(crate) fn map_service_error(error: radroots_service_sqlite::ServiceSqliteError) -> SafeError {
match error.kind() {
ServiceSqliteErrorKind::Metadata
| ServiceSqliteErrorKind::Migration
@@ -195,7 +203,7 @@ pub(crate) const fn corrupt_storage() -> SafeError {
)
}
-const fn invalid_storage_contract() -> SafeError {
+pub(crate) const fn invalid_storage_contract() -> SafeError {
SafeError::new(
SafeErrorCode::InvalidApplicationState,
SafeMessage::new("The application storage contract is invalid."),
diff --git a/core/crates/harvestcircle_storage/src/lib.rs b/core/crates/harvestcircle_storage/src/lib.rs
@@ -1,6 +1,7 @@
#![doc = "HarvestCircle persistence adapters."]
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
+mod backup;
mod contract;
mod db;
mod identities;
@@ -12,6 +13,7 @@ mod journal;
mod os_keyring;
mod profiles;
+pub use backup::{VerifiedHarvestCircleBackup, verify_harvestcircle_backup};
pub use contract::{
HARVESTCIRCLE_ACTOR_MAILBOX_CAPACITY, HARVESTCIRCLE_APPLICATION_ID,
HARVESTCIRCLE_COMMAND_DEADLINE_MAX_MS, HARVESTCIRCLE_COMMAND_DEADLINE_MIN_MS,
diff --git a/core/crates/harvestcircle_storage/tests/backup_restore.rs b/core/crates/harvestcircle_storage/tests/backup_restore.rs
@@ -0,0 +1,275 @@
+use core::num::NonZeroU64;
+use std::fs;
+
+use harvestcircle_application::{IdentityRepository, KeyMaterialProvider};
+use harvestcircle_domain::{
+ IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, SafeErrorCode,
+ SignerAvailability, UnixTimestamp,
+};
+use harvestcircle_nostr::NostrKeyMaterialProvider;
+use harvestcircle_storage::{Database, verify_harvestcircle_backup};
+use radroots_runtime_paths::{
+ InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver,
+ RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId,
+};
+use radroots_service_sqlite::{
+ BACKUP_STATE_MEMBER_NAME, BackupCreatedAtUnixMs, BackupManifestSha256, MigrationBuildIdentity,
+};
+use tempfile::{TempDir, tempdir_in};
+
+fn tempdir() -> std::io::Result<TempDir> {
+ tempdir_in(std::env::temp_dir().canonicalize()?)
+}
+
+fn runtime_context(directory: &TempDir) -> RuntimeContext {
+ RuntimeContext::resolve(
+ &RadrootsPathResolver::new(
+ RadrootsPlatform::current(),
+ RadrootsHostEnvironment::default(),
+ ),
+ RuntimeContextBootstrap::new(
+ RadrootsPathProfile::RepoLocal,
+ Some(
+ directory
+ .path()
+ .canonicalize()
+ .expect("canonical directory"),
+ ),
+ RuntimeContextSource::BootstrapCli,
+ RuntimeContextSource::SafeDefault,
+ )
+ .expect("bootstrap"),
+ ServiceId::new("harvestcircle").expect("service"),
+ InstanceId::new("desktop").expect("instance"),
+ )
+ .expect("runtime context")
+}
+
+fn build_identity() -> MigrationBuildIdentity {
+ MigrationBuildIdentity::new(
+ "0.1.0-alpha",
+ "1111111111111111111111111111111111111111",
+ "2222222222222222222222222222222222222222",
+ "1.97.1",
+ "test",
+ "test",
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ )
+ .expect("build identity")
+}
+
+fn identity(index: i64) -> NostrIdentity {
+ let (public_key, npub, secret, nsec) = NostrKeyMaterialProvider
+ .generate()
+ .expect("generated key material")
+ .into_parts();
+ drop((secret, nsec));
+ NostrIdentity::new(
+ NostrIdentityReference::verify(public_key, npub.as_str().to_owned())
+ .expect("identity reference"),
+ LocalKeyringBinding::new(public_key, SignerAvailability::Available),
+ None,
+ IdentityCreatedAt::new(UnixTimestamp::from_seconds(index).expect("time")),
+ None,
+ )
+ .expect("identity")
+}
+
+fn prepare_backup_parent(directory: &TempDir) -> std::path::PathBuf {
+ let parent = directory.path().join("backup-output");
+ fs::create_dir(&parent).expect("backup parent");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(&parent, fs::Permissions::from_mode(0o700))
+ .expect("backup parent mode");
+ }
+ parent
+}
+
+#[tokio::test]
+async fn capture_verify_restore_round_trip_is_identity_bound_and_legacy_safe() {
+ let directory = tempdir().expect("directory");
+ let context = runtime_context(&directory);
+ let build = build_identity();
+ let legacy = directory.path().join("harvestcircle.sqlite3");
+ fs::write(&legacy, b"legacy-state-remains-untouched").expect("legacy sentinel");
+
+ let mut database = Database::open(&context, 1, 1, &build)
+ .await
+ .expect("database");
+ let retained = identity(1);
+ database
+ .insert_identity(&retained)
+ .await
+ .expect("retained identity");
+ let expected = database.metadata().identity();
+
+ let backup_parent = prepare_backup_parent(&directory);
+ let bundle = backup_parent.join("bundle");
+ let manifest = database
+ .capture_online_backup(
+ &bundle,
+ BackupCreatedAtUnixMs::new(1_700_000_000_000).expect("capture time"),
+ )
+ .await
+ .expect("capture");
+ assert_eq!(
+ fs::read_dir(&bundle)
+ .expect("bundle inventory")
+ .map(|entry| entry.expect("entry").file_name())
+ .collect::<Vec<_>>(),
+ [std::ffi::OsString::from(BACKUP_STATE_MEMBER_NAME)]
+ );
+ assert!(!manifest.protected_material_included());
+
+ database
+ .insert_identity(&identity(2))
+ .await
+ .expect("post-capture identity");
+ assert_eq!(
+ database
+ .list_identities()
+ .await
+ .expect("live identities")
+ .len(),
+ 2
+ );
+
+ let member_length = manifest.members()[0].byte_length();
+ let verified = verify_harvestcircle_backup(
+ manifest.canonical_bytes(),
+ manifest.digest(),
+ &bundle,
+ &expected,
+ NonZeroU64::new(member_length).expect("member length"),
+ )
+ .expect("verified backup");
+ assert_eq!(
+ format!("{verified:?}"),
+ "VerifiedHarvestCircleBackup([redacted])"
+ );
+
+ database
+ .restore_verified_backup(&context, verified, 2, &build)
+ .await
+ .expect("restore and recovery reopen");
+ let identities = database
+ .list_identities()
+ .await
+ .expect("restored identities");
+ assert_eq!(identities.len(), 1);
+ assert_eq!(identities[0].public_key(), retained.public_key());
+ assert_eq!(database.metadata().identity(), expected);
+
+ let state_directory = context.paths().state();
+ for forbidden in [
+ "state.restore-staged.sqlite",
+ "state.restore-backup.sqlite",
+ "state.restore-marker.v1",
+ "state.restore-marker.v1.next",
+ ] {
+ assert!(
+ !state_directory.join(forbidden).exists(),
+ "retained {forbidden}"
+ );
+ }
+ assert_eq!(
+ fs::read(&legacy).expect("legacy sentinel"),
+ b"legacy-state-remains-untouched"
+ );
+ database.close().await.expect("restored close");
+}
+
+#[tokio::test]
+async fn wrong_manifest_digest_fails_before_restore_and_preserves_live_state() {
+ let directory = tempdir().expect("directory");
+ let context = runtime_context(&directory);
+ let build = build_identity();
+ let database = Database::open(&context, 1, 1, &build)
+ .await
+ .expect("database");
+ let retained = identity(1);
+ database.insert_identity(&retained).await.expect("identity");
+ let expected = database.metadata().identity();
+ let bundle = prepare_backup_parent(&directory).join("bundle");
+ let manifest = database
+ .capture_online_backup(
+ &bundle,
+ BackupCreatedAtUnixMs::new(1_700_000_000_001).expect("capture time"),
+ )
+ .await
+ .expect("capture");
+
+ let error = verify_harvestcircle_backup(
+ manifest.canonical_bytes(),
+ BackupManifestSha256::from_bytes([0; 32]),
+ &bundle,
+ &expected,
+ NonZeroU64::new(manifest.members()[0].byte_length()).expect("member length"),
+ )
+ .expect_err("wrong trusted digest");
+ assert_eq!(error.code(), SafeErrorCode::StorageBackupInvalid);
+ assert_eq!(
+ database.list_identities().await.expect("live identities")[0].public_key(),
+ retained.public_key()
+ );
+ database.close().await.expect("close");
+}
+
+#[tokio::test]
+async fn mismatched_verified_identity_is_rejected_before_live_host_close() {
+ let live_directory = tempdir().expect("live directory");
+ let source_directory = tempdir().expect("source directory");
+ let live_context = runtime_context(&live_directory);
+ let source_context = runtime_context(&source_directory);
+ let build = build_identity();
+
+ let mut live = Database::open(&live_context, 1, 1, &build)
+ .await
+ .expect("live database");
+ let retained = identity(1);
+ live.insert_identity(&retained)
+ .await
+ .expect("live identity");
+
+ let source = Database::open(&source_context, 1, 1, &build)
+ .await
+ .expect("source database");
+ let source_identity = source.metadata().identity();
+ let bundle = prepare_backup_parent(&source_directory).join("bundle");
+ let manifest = source
+ .capture_online_backup(
+ &bundle,
+ BackupCreatedAtUnixMs::new(1_700_000_000_002).expect("capture time"),
+ )
+ .await
+ .expect("capture");
+ let verified = verify_harvestcircle_backup(
+ manifest.canonical_bytes(),
+ manifest.digest(),
+ &bundle,
+ &source_identity,
+ NonZeroU64::new(manifest.members()[0].byte_length()).expect("member length"),
+ )
+ .expect("verified source backup");
+
+ let error = live
+ .restore_verified_backup(&live_context, verified, 2, &build)
+ .await
+ .expect_err("mismatched source generation");
+ assert_eq!(error.code(), SafeErrorCode::InvalidApplicationState);
+ assert_eq!(
+ live.list_identities()
+ .await
+ .expect("live host remains open")[0]
+ .public_key(),
+ retained.public_key()
+ );
+ live.close().await.expect("live close");
+ source.close().await.expect("source close");
+}
diff --git a/core/crates/harvestcircle_storage/tests/package_boundary.rs b/core/crates/harvestcircle_storage/tests/package_boundary.rs
@@ -33,6 +33,7 @@ fn storage_package_keeps_one_sqlite_authority_and_a_sealed_public_surface() {
}
for module in [
+ "backup",
"contract",
"db",
"identities",
@@ -54,6 +55,10 @@ fn storage_package_keeps_one_sqlite_authority_and_a_sealed_public_surface() {
"pub struct harvestcircle_storage::Database",
"pub async fn harvestcircle_storage::Database::open",
"pub async fn harvestcircle_storage::Database::close",
+ "pub async fn harvestcircle_storage::Database::capture_online_backup",
+ "pub async fn harvestcircle_storage::Database::restore_verified_backup",
+ "pub struct harvestcircle_storage::VerifiedHarvestCircleBackup",
+ "pub fn harvestcircle_storage::verify_harvestcircle_backup",
"impl harvestcircle_application::ports::DurableOperationRepository for harvestcircle_storage::Database",
"harvestcircle_application::ports::BoxFuture",
"pub fn harvestcircle_storage::harvestcircle_migration_catalog()",
@@ -67,6 +72,11 @@ fn storage_package_keeps_one_sqlite_authority_and_a_sealed_public_surface() {
"sqlx::",
"OperationJournal",
"harvestcircle_initial_schema_sql",
+ "VerifiedServiceBackup",
+ "StagedServiceRestore",
+ "verify_backup_bundle",
+ "stage_verified_restore",
+ "finalize_staged_restore",
"repair",
"preflight",
] {
diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs
@@ -878,6 +878,10 @@ fn provenance_check(root: &Path, inventory: &Inventory, findings: &mut Vec<Strin
"pub struct harvestcircle_storage::Database",
"pub async fn harvestcircle_storage::Database::open",
"pub async fn harvestcircle_storage::Database::close",
+ "pub async fn harvestcircle_storage::Database::capture_online_backup",
+ "pub async fn harvestcircle_storage::Database::restore_verified_backup",
+ "pub struct harvestcircle_storage::VerifiedHarvestCircleBackup",
+ "pub fn harvestcircle_storage::verify_harvestcircle_backup",
"impl harvestcircle_application::ports::DurableOperationRepository for harvestcircle_storage::Database",
"harvestcircle_application::ports::BoxFuture",
] {
@@ -891,6 +895,11 @@ fn provenance_check(root: &Path, inventory: &Inventory, findings: &mut Vec<Strin
"sqlx::",
"OperationJournal",
"harvestcircle_initial_schema_sql",
+ "VerifiedServiceBackup",
+ "StagedServiceRestore",
+ "verify_backup_bundle",
+ "stage_verified_restore",
+ "finalize_staged_restore",
"repair",
"preflight",
] {