commit c4e7d8624e927bc7a4f7699755a0e74c0881b9b6
parent c45fd07cb6e920c71dabd2d90a85f490d30e7647
Author: triesap <tyson@radroots.org>
Date: Fri, 21 Aug 2026 14:57:55 +0000
state: integrate Myc backup and recovery
Diffstat:
6 files changed, 782 insertions(+), 10 deletions(-)
diff --git a/README b/README
@@ -60,6 +60,16 @@ that transaction boundary. The v2 binding table and its update/delete guards
are checksum-pinned service-owned schema objects; later workflow tables remain
owned by their ordered repository steps.
+Writable hosts expose Myc-bound online-backup and active-integrity operations.
+Backup verification retains the exact admitted member inode, and
+offline staging derives the same runtime paths, database identity, migration
+catalog, and schema catalog from sealed Myc evidence. Finalization returns no
+open host; the next writable open alone reconciles durable recovery evidence.
+Read-only hosts cannot capture backups, and any live host prevents offline
+restore authority. Cancellation, explicit close, and cleanup behavior remain
+owned by the source-locked shared SQLx host; Myc maps every public failure to a
+stable source-free classification.
+
## NIP-46 runtime contract
Myc listens for encrypted kind-24133 requests on the exact configured relay
diff --git a/src/lib.rs b/src/lib.rs
@@ -27,6 +27,7 @@ mod signing_adapter;
pub mod sql;
mod state_catalog;
mod state_host;
+mod state_maintenance;
mod state_metadata;
mod state_repository;
pub mod transport;
@@ -120,6 +121,11 @@ pub use state_host::{
MycStateHost, MycStateHostError, MycStateHostErrorKind, MycStateHostMode, initialize_myc_state,
open_myc_state_inspection, open_myc_state_read_write,
};
+pub use state_maintenance::{
+ MycStagedStateRestore, MycStateMaintenanceError, MycStateMaintenanceErrorKind,
+ MycVerifiedStateBackup, finalize_myc_state_restore, stage_myc_state_restore,
+ verify_myc_state_backup,
+};
pub use state_metadata::{
MYC_OPERATOR_CONTRACT_VERSION, MYC_STATE_APPLICATION_ID, MycExpectedIdentities,
MycExpectedPublicIdentity, MycNormalizedConfigDigest, MycStateMetadata, MycStateMetadataError,
diff --git a/src/state_host.rs b/src/state_host.rs
@@ -1,17 +1,23 @@
//! Sealed lifecycle boundary for the canonical Myc SQLite state catalog.
use core::fmt;
-use std::{error::Error, path::PathBuf};
+use std::{
+ error::Error,
+ path::{Path, PathBuf},
+};
use radroots_service_sqlite::{
- MigrationApplicationOutcome, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode,
- ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, initialize_database,
+ BackupCreatedAtUnixMs, IntegrityCheckedAtUnixMs, MigrationApplicationOutcome,
+ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceBackupManifest,
+ ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqliteIntegrityReport,
+ ServiceSqlitePaths, initialize_database,
};
use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions};
use crate::{
- MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION, MycRuntimeContext, MycStateMetadata,
- MycStateRepository, myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs,
+ MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION, MycRuntimeContext,
+ MycStateMaintenanceError, MycStateMaintenanceErrorKind, MycStateMetadata, MycStateRepository,
+ myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs,
};
/// Stable lifecycle mode of one opened Myc state host.
@@ -148,6 +154,37 @@ impl MycStateHost {
MycStateRepository::new(&self.host, &self.metadata)
}
+ /// Captures one governed point-in-time backup from a writable Myc host.
+ ///
+ /// The staging directory must be a new absolute path. The returned
+ /// manifest remains in memory and contains no protected identity material.
+ pub async fn capture_online_backup(
+ &self,
+ staging_directory: &Path,
+ created_at: BackupCreatedAtUnixMs,
+ ) -> Result<ServiceBackupManifest, MycStateMaintenanceError> {
+ if self.mode != MycStateHostMode::ReadWriteExisting {
+ return Err(MycStateMaintenanceError::new(
+ MycStateMaintenanceErrorKind::InvalidMode,
+ ));
+ }
+ self.host
+ .capture_online_backup(staging_directory, created_at)
+ .await
+ .map_err(MycStateMaintenanceError::from_sqlite)
+ }
+
+ /// Runs one explicit bounded integrity inspection over this host.
+ pub async fn inspect_integrity(
+ &self,
+ checked_at: IntegrityCheckedAtUnixMs,
+ ) -> Result<ServiceSqliteIntegrityReport, MycStateMaintenanceError> {
+ self.host
+ .inspect_integrity(checked_at)
+ .await
+ .map_err(MycStateMaintenanceError::from_sqlite)
+ }
+
/// Drains the shared host and explicitly releases retained authority.
pub async fn close(&self) -> Result<(), MycStateHostError> {
self.host
@@ -298,12 +335,14 @@ pub async fn open_myc_state_inspection(
Ok(state)
}
-fn state_paths(runtime: &MycRuntimeContext) -> Result<ServiceSqlitePaths, MycStateHostError> {
+pub(crate) fn state_paths(
+ runtime: &MycRuntimeContext,
+) -> Result<ServiceSqlitePaths, MycStateHostError> {
ServiceSqlitePaths::from_runtime_context(runtime.context())
.map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InvalidPaths))
}
-fn require_metadata(
+pub(crate) fn require_metadata(
runtime: &MycRuntimeContext,
metadata: &MycStateMetadata,
) -> Result<(), MycStateHostError> {
@@ -349,7 +388,7 @@ fn exact_existing_outcome(outcome: MigrationApplicationOutcome) -> bool {
)
}
-fn catalogs() -> Result<
+pub(crate) fn catalogs() -> Result<
(
radroots_service_sqlite::MigrationCatalog,
radroots_service_sqlite::SchemaCatalog,
diff --git a/src/state_maintenance.rs b/src/state_maintenance.rs
@@ -0,0 +1,307 @@
+//! Myc-bound integrity, backup, and offline restore integration.
+
+use core::{fmt, num::NonZeroU64};
+use std::{error::Error, path::Path};
+
+use radroots_service_sqlite::{
+ BackupManifestSha256, ServiceBackupManifest, ServiceDatabaseMetadata, ServiceSqliteError,
+ ServiceSqliteErrorKind, StagedServiceRestore, VerifiedServiceBackup, finalize_staged_restore,
+ stage_verified_restore, verify_backup_bundle,
+};
+
+use crate::{MycRuntimeContext, MycStateMetadata, state_host};
+
+/// Stable source-free class for a Myc state-maintenance failure.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycStateMaintenanceErrorKind {
+ InvalidEvidence,
+ InvalidMode,
+ Catalog,
+ Authority,
+ Open,
+ Metadata,
+ Migration,
+ Backup,
+ Restore,
+ Integrity,
+ Recovery,
+}
+
+impl MycStateMaintenanceErrorKind {
+ /// Returns the stable machine-readable failure code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::InvalidEvidence => "state_maintenance_evidence_invalid",
+ Self::InvalidMode => "state_maintenance_mode_invalid",
+ Self::Catalog => "state_maintenance_catalog_invalid",
+ Self::Authority => "state_maintenance_authority_failed",
+ Self::Open => "state_maintenance_open_failed",
+ Self::Metadata => "state_maintenance_metadata_invalid",
+ Self::Migration => "state_maintenance_migration_invalid",
+ Self::Backup => "state_backup_failed",
+ Self::Restore => "state_restore_failed",
+ Self::Integrity => "state_integrity_failed",
+ Self::Recovery => "state_recovery_failed",
+ }
+ }
+}
+
+/// Redacted Myc state-maintenance failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct MycStateMaintenanceError {
+ kind: MycStateMaintenanceErrorKind,
+}
+
+impl MycStateMaintenanceError {
+ pub(crate) const fn new(kind: MycStateMaintenanceErrorKind) -> Self {
+ Self { kind }
+ }
+
+ pub(crate) fn from_sqlite(error: ServiceSqliteError) -> Self {
+ let kind = match error.kind() {
+ ServiceSqliteErrorKind::Authority => MycStateMaintenanceErrorKind::Authority,
+ ServiceSqliteErrorKind::Open
+ | ServiceSqliteErrorKind::Create
+ | ServiceSqliteErrorKind::Pragma => MycStateMaintenanceErrorKind::Open,
+ ServiceSqliteErrorKind::Metadata => MycStateMaintenanceErrorKind::Metadata,
+ ServiceSqliteErrorKind::Migration => MycStateMaintenanceErrorKind::Migration,
+ ServiceSqliteErrorKind::Backup => MycStateMaintenanceErrorKind::Backup,
+ ServiceSqliteErrorKind::Restore => MycStateMaintenanceErrorKind::Restore,
+ ServiceSqliteErrorKind::Integrity => MycStateMaintenanceErrorKind::Integrity,
+ ServiceSqliteErrorKind::Recovery => MycStateMaintenanceErrorKind::Recovery,
+ };
+ Self::new(kind)
+ }
+
+ /// Returns the stable failure class.
+ #[must_use]
+ pub const fn kind(self) -> MycStateMaintenanceErrorKind {
+ self.kind
+ }
+
+ /// Returns the stable machine-readable failure code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ self.kind.code()
+ }
+}
+
+impl fmt::Display for MycStateMaintenanceError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ MycStateMaintenanceErrorKind::InvalidEvidence => {
+ "Myc state maintenance evidence is invalid"
+ }
+ MycStateMaintenanceErrorKind::InvalidMode => {
+ "Myc state maintenance is unavailable in this host mode"
+ }
+ MycStateMaintenanceErrorKind::Catalog => "Myc state catalogs are invalid",
+ MycStateMaintenanceErrorKind::Authority => {
+ "Myc state maintenance authority could not be established"
+ }
+ MycStateMaintenanceErrorKind::Open => "Myc state maintenance could not open state",
+ MycStateMaintenanceErrorKind::Metadata => "Myc state metadata is invalid",
+ MycStateMaintenanceErrorKind::Migration => "Myc state migration history is invalid",
+ MycStateMaintenanceErrorKind::Backup => "Myc state backup failed",
+ MycStateMaintenanceErrorKind::Restore => "Myc state restore failed",
+ MycStateMaintenanceErrorKind::Integrity => "Myc state integrity check failed",
+ MycStateMaintenanceErrorKind::Recovery => "Myc state recovery failed",
+ })
+ }
+}
+
+impl fmt::Debug for MycStateMaintenanceError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycStateMaintenanceError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl Error for MycStateMaintenanceError {}
+
+/// Retained exact-inode proof of one verified Myc backup.
+///
+/// Construction is sealed to [`verify_myc_state_backup`]. No raw descriptor or
+/// pathname is exposed.
+///
+/// ```compile_fail
+/// use myc::MycVerifiedStateBackup;
+/// let _ = MycVerifiedStateBackup { inner: todo!() };
+/// ```
+pub struct MycVerifiedStateBackup {
+ inner: VerifiedServiceBackup,
+}
+
+impl MycVerifiedStateBackup {
+ /// Returns the admitted canonical manifest.
+ #[must_use]
+ pub const fn manifest(&self) -> &ServiceBackupManifest {
+ self.inner.manifest()
+ }
+
+ /// Returns the actual immutable database metadata read from the retained member.
+ #[must_use]
+ pub const fn database_metadata(&self) -> &ServiceDatabaseMetadata {
+ self.inner.database_metadata()
+ }
+}
+
+impl fmt::Debug for MycVerifiedStateBackup {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("MycVerifiedStateBackup([redacted])")
+ }
+}
+
+/// Offline staged Myc replacement that retains exclusive writer authority.
+///
+/// Construction is sealed to [`stage_myc_state_restore`]. Dropping this value
+/// preserves the shared exact-inode cleanup and fail-closed evidence contract.
+///
+/// ```compile_fail
+/// use myc::MycStagedStateRestore;
+/// let _ = MycStagedStateRestore { inner: todo!() };
+/// ```
+pub struct MycStagedStateRestore {
+ inner: StagedServiceRestore,
+}
+
+impl fmt::Debug for MycStagedStateRestore {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("MycStagedStateRestore([redacted])")
+ }
+}
+
+/// Verifies an untrusted backup bundle against one sealed Myc state identity.
+pub fn verify_myc_state_backup(
+ manifest_bytes: &[u8],
+ expected_manifest_digest: BackupManifestSha256,
+ bundle_directory: &Path,
+ expected: &MycStateMetadata,
+ maximum_state_bytes: NonZeroU64,
+) -> Result<MycVerifiedStateBackup, MycStateMaintenanceError> {
+ verify_backup_bundle(
+ manifest_bytes,
+ expected_manifest_digest,
+ bundle_directory,
+ &expected.database_identity(),
+ maximum_state_bytes,
+ )
+ .map(|inner| MycVerifiedStateBackup { inner })
+ .map_err(MycStateMaintenanceError::from_sqlite)
+}
+
+/// Copies and fully reverifies a verified backup beside closed Myc state.
+///
+/// This operation acquires exclusive writer authority. It never creates a
+/// recovery marker or replaces the live database.
+pub async fn stage_myc_state_restore(
+ runtime: &MycRuntimeContext,
+ expected: &MycStateMetadata,
+ verified: MycVerifiedStateBackup,
+) -> Result<MycStagedStateRestore, MycStateMaintenanceError> {
+ state_host::require_metadata(runtime, expected).map_err(|_| {
+ MycStateMaintenanceError::new(MycStateMaintenanceErrorKind::InvalidEvidence)
+ })?;
+ let paths = state_host::state_paths(runtime).map_err(|_| {
+ MycStateMaintenanceError::new(MycStateMaintenanceErrorKind::InvalidEvidence)
+ })?;
+ let (migrations, schema) = state_host::catalogs()
+ .map_err(|_| MycStateMaintenanceError::new(MycStateMaintenanceErrorKind::Catalog))?;
+ stage_verified_restore(
+ &paths,
+ &expected.database_identity(),
+ &migrations,
+ &schema,
+ verified.inner,
+ )
+ .await
+ .map(|inner| MycStagedStateRestore { inner })
+ .map_err(MycStateMaintenanceError::from_sqlite)
+}
+
+/// Atomically installs a completely verified staged Myc restore.
+///
+/// Success intentionally returns no open host. The next writable open owns
+/// exact recovery evidence reconciliation before SQLite is exposed again.
+pub async fn finalize_myc_state_restore(
+ staged: MycStagedStateRestore,
+) -> Result<(), MycStateMaintenanceError> {
+ finalize_staged_restore(staged.inner)
+ .await
+ .map_err(MycStateMaintenanceError::from_sqlite)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn shared_failures_map_to_the_closed_source_free_myc_vocabulary() {
+ for (source, expected) in [
+ (
+ ServiceSqliteErrorKind::Authority,
+ MycStateMaintenanceErrorKind::Authority,
+ ),
+ (
+ ServiceSqliteErrorKind::Open,
+ MycStateMaintenanceErrorKind::Open,
+ ),
+ (
+ ServiceSqliteErrorKind::Create,
+ MycStateMaintenanceErrorKind::Open,
+ ),
+ (
+ ServiceSqliteErrorKind::Pragma,
+ MycStateMaintenanceErrorKind::Open,
+ ),
+ (
+ ServiceSqliteErrorKind::Metadata,
+ MycStateMaintenanceErrorKind::Metadata,
+ ),
+ (
+ ServiceSqliteErrorKind::Migration,
+ MycStateMaintenanceErrorKind::Migration,
+ ),
+ (
+ ServiceSqliteErrorKind::Backup,
+ MycStateMaintenanceErrorKind::Backup,
+ ),
+ (
+ ServiceSqliteErrorKind::Restore,
+ MycStateMaintenanceErrorKind::Restore,
+ ),
+ (
+ ServiceSqliteErrorKind::Integrity,
+ MycStateMaintenanceErrorKind::Integrity,
+ ),
+ (
+ ServiceSqliteErrorKind::Recovery,
+ MycStateMaintenanceErrorKind::Recovery,
+ ),
+ ] {
+ let mapped = MycStateMaintenanceError::from_sqlite(ServiceSqliteError::with_source(
+ source,
+ SensitiveSource,
+ ));
+ assert_eq!(mapped.kind(), expected);
+ assert!(Error::source(&mapped).is_none());
+ let rendered = format!("{mapped} {mapped:?}");
+ assert!(!rendered.contains("sensitive"));
+ assert!(!mapped.code().is_empty());
+ }
+ }
+
+ #[derive(Debug)]
+ struct SensitiveSource;
+
+ impl fmt::Display for SensitiveSource {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("sensitive /tmp/state.sqlite")
+ }
+ }
+
+ impl Error for SensitiveSource {}
+}
diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs
@@ -2432,6 +2432,9 @@ async fn live_listener_works_with_sqlite_signer_state_and_runtime_audit() -> Tes
assert_eq!(outbox_records[0].status, MycDeliveryOutboxStatus::Failed);
assert_eq!(outbox_records[1].status, MycDeliveryOutboxStatus::Finalized);
+ let _ = shutdown_tx.send(());
+ listener_task.await??;
+
let restarted_runtime = MycRuntime::bootstrap(runtime.config().clone())?;
assert_eq!(
restarted_runtime
@@ -2482,8 +2485,6 @@ async fn live_listener_works_with_sqlite_signer_state_and_runtime_audit() -> Tes
Some("SQLite Client")
);
- let _ = shutdown_tx.send(());
- listener_task.await??;
Ok(())
}
diff --git a/tests/services_hardening_state_resilience.rs b/tests/services_hardening_state_resilience.rs
@@ -0,0 +1,409 @@
+#![forbid(unsafe_code)]
+#![cfg(any(target_os = "linux", target_os = "macos"))]
+
+use std::{
+ error::Error,
+ fs,
+ num::NonZeroU64,
+ os::unix::fs::PermissionsExt,
+ path::{Path, PathBuf},
+ time::Duration,
+};
+
+use myc::{
+ MycConfigProfile, MycStateHostErrorKind, MycStateMaintenanceErrorKind, MycStateMetadata,
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, finalize_myc_state_restore,
+ initialize_myc_state, open_myc_state_inspection, open_myc_state_read_write,
+ parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context,
+ stage_myc_state_restore, verify_myc_state_backup,
+};
+use radroots_service_sqlite::{
+ BackupCreatedAtUnixMs, IntegrityCheckOutcome, IntegrityCheckedAtUnixMs,
+ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity,
+};
+use radroots_storage::event::SourceGeneration;
+use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions};
+
+const CONFIG_EXAMPLE: &[u8] =
+ include_bytes!("../contracts/services_hardening/config.v1.example.toml");
+const LIB_SOURCE: &str = include_str!("../src/lib.rs");
+const HOST_SOURCE: &str = include_str!("../src/state_host.rs");
+const MAINTENANCE_SOURCE: &str = include_str!("../src/state_maintenance.rs");
+
+fn runtime(root: &Path, instance: &str) -> myc::MycRuntimeContext {
+ let invocation = parse_myc_cli_v1_from([
+ "myc",
+ "--profile",
+ "repo-local",
+ "--instance",
+ instance,
+ "--repo-local-root",
+ root.to_str().expect("UTF-8 temporary root"),
+ "run",
+ ])
+ .expect("valid invocation");
+ resolve_myc_runtime_context(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ &invocation,
+ )
+ .expect("runtime context")
+}
+
+fn prepare_state_directory(runtime: &myc::MycRuntimeContext) {
+ let directory = runtime.context().paths().state();
+ fs::create_dir_all(directory).expect("state directory");
+ fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("state mode");
+}
+
+fn metadata(runtime: &myc::MycRuntimeContext) -> MycStateMetadata {
+ let configuration =
+ parse_myc_config_v1(CONFIG_EXAMPLE, MycConfigProfile::RepoLocal).expect("configuration");
+ MycStateMetadata::new(
+ runtime,
+ &configuration,
+ SourceGeneration::new([0x5a; 32]).expect("generation"),
+ 1_725_000_000_000,
+ )
+ .expect("metadata")
+}
+
+fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) {
+ let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("migration time");
+ let build = MigrationBuildIdentity::new(
+ env!("CARGO_PKG_VERSION"),
+ "1111111111111111111111111111111111111111",
+ "b44119fbac5985be8127ad1bf56d2950e6399427",
+ "rustc-test",
+ "test-target",
+ "service-host",
+ 1,
+ myc::MYC_STATE_SCHEMA_VERSION,
+ 1,
+ 1,
+ 1,
+ )
+ .expect("build identity");
+ (applied_at, build)
+}
+
+fn recovery_paths(runtime: &myc::MycRuntimeContext) -> [PathBuf; 4] {
+ let state = runtime.context().paths().state();
+ [
+ state.join("state.restore-staged.sqlite"),
+ state.join("state.restore-backup.sqlite"),
+ state.join("state.restore-marker.v1"),
+ state.join("state.restore-marker.v1.next"),
+ ]
+}
+
+fn directory_inventory(directory: &Path) -> Vec<String> {
+ let mut entries = fs::read_dir(directory)
+ .expect("state directory")
+ .map(|entry| {
+ entry
+ .expect("state entry")
+ .file_name()
+ .to_string_lossy()
+ .into_owned()
+ })
+ .collect::<Vec<_>>();
+ entries.sort();
+ entries
+}
+
+#[tokio::test]
+async fn backup_integrity_and_offline_restore_obey_one_exact_myc_authority() {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let runtime = runtime(directory.path(), "primary");
+ prepare_state_directory(&runtime);
+ let metadata = metadata(&runtime);
+ let (applied_at, build) = migration_evidence();
+ initialize_myc_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("initialization");
+
+ let writer = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("writable host");
+ let cancelled_bundle = directory.path().join("cancelled-backup");
+ let cancelled = tokio::time::timeout(
+ Duration::from_nanos(1),
+ writer.capture_online_backup(
+ &cancelled_bundle,
+ BackupCreatedAtUnixMs::new(1_725_000_000_100).expect("capture time"),
+ ),
+ )
+ .await;
+ assert!(cancelled.is_err(), "capture future must be cancellable");
+ writer
+ .close()
+ .await
+ .expect("close drains cancelled capture cleanup");
+ assert!(!cancelled_bundle.exists());
+
+ let writer = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("writer reacquisition after cancelled capture");
+ let cancelled_integrity = tokio::time::timeout(
+ Duration::from_nanos(1),
+ writer.inspect_integrity(
+ IntegrityCheckedAtUnixMs::new(1_725_000_000_200).expect("inspection time"),
+ ),
+ )
+ .await;
+ assert!(
+ cancelled_integrity.is_err(),
+ "integrity future must be cancellable"
+ );
+ let report = writer
+ .inspect_integrity(
+ IntegrityCheckedAtUnixMs::new(1_725_000_000_201).expect("retry inspection time"),
+ )
+ .await
+ .expect("integrity retry after cancellation");
+ assert_eq!(report.sqlite(), IntegrityCheckOutcome::Verified);
+ assert_eq!(report.foreign_keys(), IntegrityCheckOutcome::Verified);
+ assert!(report.diagnostics().is_empty());
+
+ let bundle = directory.path().join("backup");
+ let manifest = writer
+ .capture_online_backup(
+ &bundle,
+ BackupCreatedAtUnixMs::new(1_725_000_000_300).expect("capture time"),
+ )
+ .await
+ .expect("online backup");
+ assert_eq!(manifest.service().as_str(), "myc");
+ assert_eq!(manifest.instance().as_str(), "primary");
+ assert_eq!(manifest.state_schema_version().get(), 2);
+ assert!(!manifest.protected_material_included());
+ let members = fs::read_dir(&bundle)
+ .expect("backup directory")
+ .map(|entry| entry.expect("entry").file_name())
+ .collect::<Vec<_>>();
+ assert_eq!(members, ["state.sqlite"]);
+ let manifest_bytes = manifest.canonical_bytes().to_vec();
+ let manifest_digest = manifest.digest();
+ let maximum_state_bytes =
+ NonZeroU64::new(manifest.members()[0].byte_length()).expect("nonzero captured member");
+ writer.close().await.expect("writer close");
+
+ let live_path = runtime.artifacts().state_database();
+ let state_directory = runtime.context().paths().state();
+ let live_bytes_before_inspection = fs::read(live_path).expect("live bytes");
+ let live_modified_before_inspection = fs::metadata(live_path)
+ .expect("live metadata")
+ .modified()
+ .expect("live modified time");
+ let inventory_before_inspection = directory_inventory(state_directory);
+ let inspection = open_myc_state_inspection(&runtime, &metadata)
+ .await
+ .expect("read-only inspection");
+ let inspection_report = inspection
+ .inspect_integrity(
+ IntegrityCheckedAtUnixMs::new(1_725_000_000_350).expect("inspection time"),
+ )
+ .await
+ .expect("read-only integrity inspection");
+ assert_eq!(inspection_report.sqlite(), IntegrityCheckOutcome::Verified);
+ assert_eq!(
+ inspection_report.foreign_keys(),
+ IntegrityCheckOutcome::Verified
+ );
+ let forbidden_bundle = directory.path().join("inspection-backup");
+ let error = inspection
+ .capture_online_backup(
+ &forbidden_bundle,
+ BackupCreatedAtUnixMs::new(1_725_000_000_400).expect("capture time"),
+ )
+ .await
+ .expect_err("read-only capture");
+ assert_eq!(error.kind(), MycStateMaintenanceErrorKind::InvalidMode);
+ assert!(!forbidden_bundle.exists());
+
+ let verified = verify_myc_state_backup(
+ &manifest_bytes,
+ manifest_digest,
+ &bundle,
+ &metadata,
+ maximum_state_bytes,
+ )
+ .expect("verified retained backup");
+ let contended = stage_myc_state_restore(&runtime, &metadata, verified)
+ .await
+ .expect_err("offline staging must reject a live inspection host");
+ assert_eq!(contended.kind(), MycStateMaintenanceErrorKind::Authority);
+ inspection.close().await.expect("inspection close");
+ assert_eq!(
+ fs::read(live_path).expect("live bytes after inspection"),
+ live_bytes_before_inspection
+ );
+ assert_eq!(
+ fs::metadata(live_path)
+ .expect("live metadata after inspection")
+ .modified()
+ .expect("live modified time after inspection"),
+ live_modified_before_inspection
+ );
+ assert_eq!(
+ directory_inventory(state_directory),
+ inventory_before_inspection
+ );
+
+ let verified = verify_myc_state_backup(
+ &manifest_bytes,
+ manifest_digest,
+ &bundle,
+ &metadata,
+ maximum_state_bytes,
+ )
+ .expect("reverified backup for runtime mismatch");
+ let secondary = self::runtime(directory.path(), "secondary");
+ let mismatch = stage_myc_state_restore(&secondary, &metadata, verified)
+ .await
+ .expect_err("runtime and metadata must remain cross-bound");
+ assert_eq!(
+ mismatch.kind(),
+ MycStateMaintenanceErrorKind::InvalidEvidence
+ );
+ assert!(!secondary.artifacts().state_database().exists());
+ assert!(recovery_paths(&secondary).iter().all(|path| !path.exists()));
+
+ let verified = verify_myc_state_backup(
+ &manifest_bytes,
+ manifest_digest,
+ &bundle,
+ &metadata,
+ maximum_state_bytes,
+ )
+ .expect("reverified backup");
+ assert_eq!(
+ format!("{verified:?}"),
+ "MycVerifiedStateBackup([redacted])"
+ );
+ assert_eq!(verified.database_metadata().state_schema_version().get(), 2);
+ let staged = stage_myc_state_restore(&runtime, &metadata, verified)
+ .await
+ .expect("offline staging");
+ assert_eq!(format!("{staged:?}"), "MycStagedStateRestore([redacted])");
+ finalize_myc_state_restore(staged)
+ .await
+ .expect("atomic finalization");
+
+ let unavailable = open_myc_state_inspection(&runtime, &metadata)
+ .await
+ .expect_err("inspection never performs restore recovery");
+ assert_eq!(unavailable.kind(), MycStateHostErrorKind::InspectionOpen);
+ let recovered = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("writable open reconciles exact recovery evidence");
+ recovered
+ .repository()
+ .verify_binding()
+ .await
+ .expect("restored Myc binding");
+ recovered.close().await.expect("recovered writer close");
+ for path in recovery_paths(&runtime) {
+ assert!(!path.exists(), "recovery evidence must be retired");
+ }
+}
+
+#[tokio::test]
+async fn exact_open_rejects_migration_history_drift_without_repair() {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let runtime = runtime(directory.path(), "primary");
+ prepare_state_directory(&runtime);
+ let metadata = metadata(&runtime);
+ let (applied_at, build) = migration_evidence();
+ initialize_myc_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("initialization");
+
+ let options = SqliteConnectOptions::new()
+ .filename(runtime.artifacts().state_database())
+ .create_if_missing(false)
+ .disable_statement_logging();
+ let mut connection = SqliteConnection::connect_with(&options)
+ .await
+ .expect("test-only offline connection");
+ sqlx::query("DROP TRIGGER schema_migrations_no_delete")
+ .execute(&mut connection)
+ .await
+ .expect("remove immutable test guard");
+ sqlx::query("DELETE FROM schema_migrations WHERE version = 2")
+ .execute(&mut connection)
+ .await
+ .expect("create invalid migration prefix");
+ connection.close().await.expect("test connection close");
+
+ let error = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect_err("migration drift must fail closed");
+ assert_eq!(error.kind(), MycStateHostErrorKind::ReadWriteOpen);
+ let inspection = open_myc_state_inspection(&runtime, &metadata)
+ .await
+ .expect_err("inspection must reject migration drift");
+ assert_eq!(inspection.kind(), MycStateHostErrorKind::InspectionOpen);
+}
+
+#[test]
+fn maintenance_boundary_is_sealed_source_free_and_sqlx_owned() {
+ assert!(LIB_SOURCE.contains("mod state_maintenance;"));
+ assert!(!LIB_SOURCE.contains("pub mod state_maintenance;"));
+ assert!(HOST_SOURCE.contains(".capture_online_backup(staging_directory, created_at)"));
+ assert!(HOST_SOURCE.contains(".inspect_integrity(checked_at)"));
+ assert!(MAINTENANCE_SOURCE.contains("verify_backup_bundle("));
+ assert!(MAINTENANCE_SOURCE.contains("stage_verified_restore("));
+ assert!(MAINTENANCE_SOURCE.contains("finalize_staged_restore("));
+ for forbidden in [
+ "sqlx::",
+ "SqliteConnection",
+ "SqlitePool",
+ "raw_sql",
+ "BEGIN ",
+ "COMMIT",
+ "ROLLBACK",
+ "std::fs",
+ "std::env",
+ "std::time",
+ "provider",
+ "relay",
+ "tokio::spawn",
+ "spawn_blocking",
+ ] {
+ assert!(
+ !MAINTENANCE_SOURCE.contains(forbidden),
+ "found forbidden maintenance authority `{forbidden}`"
+ );
+ }
+
+ for kind in [
+ MycStateMaintenanceErrorKind::InvalidEvidence,
+ MycStateMaintenanceErrorKind::InvalidMode,
+ MycStateMaintenanceErrorKind::Catalog,
+ MycStateMaintenanceErrorKind::Authority,
+ MycStateMaintenanceErrorKind::Open,
+ MycStateMaintenanceErrorKind::Metadata,
+ MycStateMaintenanceErrorKind::Migration,
+ MycStateMaintenanceErrorKind::Backup,
+ MycStateMaintenanceErrorKind::Restore,
+ MycStateMaintenanceErrorKind::Integrity,
+ MycStateMaintenanceErrorKind::Recovery,
+ ] {
+ assert!(!kind.code().is_empty());
+ }
+
+ let error = verify_myc_state_backup(
+ b"/tmp/secret-state.sqlite",
+ radroots_service_sqlite::BackupManifestSha256::from_bytes([0x11; 32]),
+ Path::new("/tmp/secret-bundle"),
+ &metadata(&runtime(Path::new("/tmp/secret-root"), "primary")),
+ NonZeroU64::new(1).expect("limit"),
+ )
+ .expect_err("invalid manifest");
+ assert!(Error::source(&error).is_none());
+ let rendered = format!("{error} {error:?}");
+ assert!(!rendered.contains("secret"));
+ assert!(!rendered.contains("/tmp"));
+ assert!(!rendered.contains("sqlite"));
+}