commit cc7fb52923af03e48d875af908723da2e3339e50
parent 07480ee68e9f3ee9357527d4c382f1c36e3520bd
Author: triesap <tyson@radroots.org>
Date: Sun, 23 Aug 2026 20:39:59 +0000
state: seal RHI SQLite host lifecycle
Diffstat:
8 files changed, 527 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1866,13 +1866,16 @@ dependencies = [
"radroots_secrets",
"radroots_service_host",
"radroots_service_sqlite",
+ "radroots_storage",
"radroots_trade",
"rand 0.9.2",
"serde",
"serde_json",
"sha2",
+ "sqlx",
"tempfile",
"thiserror 2.0.18",
+ "tokio",
"toml",
"url",
"zeroize",
diff --git a/Cargo.toml b/Cargo.toml
@@ -63,6 +63,7 @@ rand = { version = "0.9" }
serde = { version = "1", default-features = false }
serde_json = { version = "1", default-features = false }
sha2 = { version = "0.10" }
+sqlx = { version = "0.9.0", default-features = false, features = ["sqlite-bundled"] }
thiserror = { version = "2" }
tempfile = { version = "3" }
toml = { version = "0.8" }
@@ -70,3 +71,5 @@ url = "2"
zeroize = { version = "1" }
[dev-dependencies]
+radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha", default-features = false }
+tokio = { version = "1", default-features = false, features = ["macros", "rt-multi-thread"] }
diff --git a/README b/README
@@ -96,6 +96,14 @@ initialization and existing-only host lifecycle remain separate boundaries.
Service-owned evidence and attestation tables and their ordered migrations are
introduced only by their owning later checkpoints.
+The sealed RHI state-host lifecycle now reserves and initializes a missing
+canonical database only through an explicit create-new operation. Ordinary
+writable and immutable inspection modes open existing state only, validate the
+exact schema and migration identities, and retain mutually exclusive shared
+authority until explicit idempotent close. Missing state is never initialized
+by an open operation. No raw host, pool, connection, transaction, executor, or
+path escapes the RHI wrapper.
+
Validate the standalone crate through extbuild:
```text
diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml
@@ -7,7 +7,7 @@ architecture = "radroots.crates.release.v2"
workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4"
version = "0.1.0-alpha"
source_archive_sha256 = "b425371c134be96cce46b37f7035d6212f1efe8cff50bef366631ba5632991b0"
-cargo_lock_sha256 = "407af5f68ddb487db8867bb84e20c8a4645351c96c3695ae41973c4ea4b44a12"
+cargo_lock_sha256 = "26cb57d0658c9dbb1824f8bcc97d8a43451d1937d5ced8f3af418ce987ef3ec8"
rust_version = "1.97.1"
host_feature_profile = "service-host"
diff --git a/src/lib.rs b/src/lib.rs
@@ -8,6 +8,7 @@ pub mod host_identity;
pub mod identity_storage;
mod runtime_context;
mod state_catalog;
+mod state_host;
pub use cli_v1::{
RhiBootstrapProfileV1, RhiCliInvocationV1, RhiCliOutputModeV1, RhiCliV1Error,
@@ -36,3 +37,7 @@ pub use state_catalog::{
RhiStateCatalogError, RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog,
validate_rhi_state_catalogs,
};
+pub use state_host::{
+ RhiStateHost, RhiStateHostError, RhiStateHostErrorKind, RhiStateHostMode, initialize_rhi_state,
+ open_rhi_state_inspection, open_rhi_state_read_write,
+};
diff --git a/src/state_host.rs b/src/state_host.rs
@@ -0,0 +1,313 @@
+//! Sealed lifecycle boundary for the canonical RHI SQLite state catalog.
+
+use core::fmt;
+use std::{error::Error, path::PathBuf};
+
+use radroots_service_sqlite::{
+ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceDatabaseIdentity,
+ ServiceDatabaseMetadata, ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths,
+ initialize_database,
+};
+use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions};
+
+use crate::{
+ RHI_STATE_SCHEMA_VERSION, RhiRuntimeContext, rhi_migration_catalog, rhi_schema_catalog,
+ validate_rhi_state_catalogs,
+};
+
+/// Stable lifecycle mode of one opened RHI state host.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiStateHostMode {
+ ReadWriteExisting,
+ ReadOnlyInspection,
+}
+
+/// Stable source-free class for an RHI state-host lifecycle failure.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiStateHostErrorKind {
+ InvalidPaths,
+ InvalidEvidence,
+ Catalog,
+ Initialize,
+ ReadWriteOpen,
+ InspectionOpen,
+ Close,
+}
+
+impl RhiStateHostErrorKind {
+ /// Returns the stable machine-readable failure code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::InvalidPaths => "state_paths_invalid",
+ Self::InvalidEvidence => "state_evidence_invalid",
+ Self::Catalog => "state_catalog_invalid",
+ Self::Initialize => "state_initialize_failed",
+ Self::ReadWriteOpen => "state_read_write_open_failed",
+ Self::InspectionOpen => "state_inspection_open_failed",
+ Self::Close => "state_close_failed",
+ }
+ }
+}
+
+/// Redacted RHI state-host lifecycle failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct RhiStateHostError {
+ kind: RhiStateHostErrorKind,
+}
+
+impl RhiStateHostError {
+ const fn new(kind: RhiStateHostErrorKind) -> Self {
+ Self { kind }
+ }
+
+ /// Returns the stable failure class.
+ #[must_use]
+ pub const fn kind(self) -> RhiStateHostErrorKind {
+ 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 RhiStateHostError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ RhiStateHostErrorKind::InvalidPaths => "RHI state paths are invalid",
+ RhiStateHostErrorKind::InvalidEvidence => "RHI state identity evidence is invalid",
+ RhiStateHostErrorKind::Catalog => "RHI state catalogs are invalid",
+ RhiStateHostErrorKind::Initialize => "RHI state initialization failed",
+ RhiStateHostErrorKind::ReadWriteOpen => "RHI writable state could not be opened",
+ RhiStateHostErrorKind::InspectionOpen => "RHI inspection state could not be opened",
+ RhiStateHostErrorKind::Close => "RHI state host could not be closed",
+ })
+ }
+}
+
+impl fmt::Debug for RhiStateHostError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiStateHostError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl Error for RhiStateHostError {}
+
+/// One opened RHI state catalog whose raw SQLite authority remains sealed.
+///
+/// Callers cannot construct the wrapper or extract the shared host:
+///
+/// ```compile_fail
+/// use rhi::{RhiStateHost, RhiStateHostMode};
+///
+/// let _ = RhiStateHost {
+/// host: todo!(),
+/// mode: RhiStateHostMode::ReadWriteExisting,
+/// };
+/// ```
+///
+/// The wrapper intentionally exposes no transaction or connection escape:
+///
+/// ```compile_fail
+/// use rhi::RhiStateHost;
+///
+/// fn bypass(host: &RhiStateHost) {
+/// let _ = host.transaction(|_| async { Ok::<_, ()>(()) });
+/// }
+/// ```
+pub struct RhiStateHost {
+ host: ServiceSqliteHost,
+ mode: RhiStateHostMode,
+}
+
+impl RhiStateHost {
+ /// Returns the lifecycle mode selected when this host was opened.
+ #[must_use]
+ pub const fn mode(&self) -> RhiStateHostMode {
+ self.mode
+ }
+
+ /// Drains the shared host and explicitly releases retained authority.
+ pub async fn close(&self) -> Result<(), RhiStateHostError> {
+ self.host
+ .close()
+ .await
+ .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Close))
+ }
+}
+
+impl fmt::Debug for RhiStateHost {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiStateHost")
+ .field("mode", &self.mode)
+ .field("state", &"[sealed]")
+ .finish()
+ }
+}
+
+/// Creates a missing RHI catalog exactly once and releases initialization authority.
+///
+/// This function never opens an existing database as initialization. The caller
+/// injects the shared metadata evidence; Step 171 owns its exact RHI application,
+/// configuration, evidence-policy, identity, and contract-version bindings.
+pub async fn initialize_rhi_state(
+ runtime: &RhiRuntimeContext,
+ metadata: &ServiceDatabaseMetadata,
+) -> Result<(), RhiStateHostError> {
+ let paths = state_paths(runtime)?;
+ require_metadata(runtime, metadata)?;
+ let (migrations, schema) = catalogs()?;
+ let mut authority = initialize_database(
+ &paths,
+ OpenMode::Initialize,
+ metadata,
+ &schema,
+ initialize_empty_catalog,
+ )
+ .await
+ .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Initialize))?;
+ authority
+ .release()
+ .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Initialize))?;
+ drop(migrations);
+ Ok(())
+}
+
+/// Opens an already initialized RHI catalog with exclusive writer authority.
+///
+/// Missing state is never created. Migration time and build identity remain
+/// explicit injected evidence even while the baseline migration catalog is
+/// empty.
+pub async fn open_rhi_state_read_write(
+ runtime: &RhiRuntimeContext,
+ identity: &ServiceDatabaseIdentity,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+) -> Result<RhiStateHost, RhiStateHostError> {
+ let paths = state_paths(runtime)?;
+ require_identity(runtime, identity)?;
+ let (migrations, schema) = catalogs()?;
+ let (host, outcome) = ServiceSqliteHost::open_read_write_existing(
+ &paths,
+ identity,
+ &migrations,
+ &schema,
+ ServiceSqliteConnectionOptions::reviewed(),
+ applied_at,
+ build,
+ &[],
+ )
+ .await
+ .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::ReadWriteOpen))?;
+ if outcome.initial_version() != RHI_STATE_SCHEMA_VERSION
+ || outcome.final_version() != RHI_STATE_SCHEMA_VERSION
+ || outcome.applied_count() != 0
+ {
+ let _ = host.close().await;
+ return Err(RhiStateHostError::new(RhiStateHostErrorKind::Catalog));
+ }
+ Ok(RhiStateHost {
+ host,
+ mode: RhiStateHostMode::ReadWriteExisting,
+ })
+}
+
+/// Opens an already initialized RHI catalog for immutable inspection.
+pub async fn open_rhi_state_inspection(
+ runtime: &RhiRuntimeContext,
+ identity: &ServiceDatabaseIdentity,
+) -> Result<RhiStateHost, RhiStateHostError> {
+ let paths = state_paths(runtime)?;
+ require_identity(runtime, identity)?;
+ let (migrations, schema) = catalogs()?;
+ let host = ServiceSqliteHost::open_read_only_inspection(
+ &paths,
+ identity,
+ &migrations,
+ &schema,
+ ServiceSqliteConnectionOptions::reviewed(),
+ )
+ .await
+ .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::InspectionOpen))?;
+ Ok(RhiStateHost {
+ host,
+ mode: RhiStateHostMode::ReadOnlyInspection,
+ })
+}
+
+fn state_paths(runtime: &RhiRuntimeContext) -> Result<ServiceSqlitePaths, RhiStateHostError> {
+ ServiceSqlitePaths::from_runtime_context(runtime.context())
+ .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::InvalidPaths))
+}
+
+fn require_metadata(
+ runtime: &RhiRuntimeContext,
+ metadata: &ServiceDatabaseMetadata,
+) -> Result<(), RhiStateHostError> {
+ let matches = metadata.service() == runtime.context().service()
+ && metadata.instance() == runtime.context().instance()
+ && metadata.state_schema_version().get() == RHI_STATE_SCHEMA_VERSION;
+ matches
+ .then_some(())
+ .ok_or_else(|| RhiStateHostError::new(RhiStateHostErrorKind::InvalidEvidence))
+}
+
+fn require_identity(
+ runtime: &RhiRuntimeContext,
+ identity: &ServiceDatabaseIdentity,
+) -> Result<(), RhiStateHostError> {
+ let matches = identity.service() == runtime.context().service()
+ && identity.instance() == runtime.context().instance()
+ && identity.supported_state_schema_version().get() == RHI_STATE_SCHEMA_VERSION;
+ matches
+ .then_some(())
+ .ok_or_else(|| RhiStateHostError::new(RhiStateHostErrorKind::InvalidEvidence))
+}
+
+fn catalogs() -> Result<
+ (
+ radroots_service_sqlite::MigrationCatalog,
+ radroots_service_sqlite::SchemaCatalog,
+ ),
+ RhiStateHostError,
+> {
+ let migrations = rhi_migration_catalog()
+ .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?;
+ let schema =
+ rhi_schema_catalog().map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?;
+ validate_rhi_state_catalogs(&migrations, &schema)
+ .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?;
+ Ok((migrations, schema))
+}
+
+#[derive(Debug)]
+struct EmptyCatalogInitializationError;
+
+impl fmt::Display for EmptyCatalogInitializationError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("RHI baseline database reservation could not be opened")
+ }
+}
+
+impl Error for EmptyCatalogInitializationError {}
+
+async fn initialize_empty_catalog(path: PathBuf) -> Result<(), EmptyCatalogInitializationError> {
+ let options = SqliteConnectOptions::new()
+ .filename(path)
+ .create_if_missing(false)
+ .disable_statement_logging();
+ let connection = SqliteConnection::connect_with(&options)
+ .await
+ .map_err(|_| EmptyCatalogInitializationError)?;
+ connection
+ .close()
+ .await
+ .map_err(|_| EmptyCatalogInitializationError)
+}
diff --git a/tests/build_policy.rs b/tests/build_policy.rs
@@ -62,6 +62,13 @@ fn shared_service_sqlite_is_the_only_catalog_authority() {
}
#[test]
+fn shared_storage_test_evidence_is_exactly_source_locked() {
+ assert!(MANIFEST.contains(
+ "radroots_storage = { git = \"https://github.com/radrootslabs/lib\", rev = \"7d7b454b4c9ed86569671993bd03ca868b676665\", version = \"=0.1.0-alpha\", default-features = false }"
+ ));
+}
+
+#[test]
fn source_lock_binds_the_current_cargo_lock() {
let digest = lower_hex(&Sha256::digest(include_bytes!("../Cargo.lock")));
assert!(SOURCE_LOCK.starts_with(
diff --git a/tests/services_hardening_state_host.rs b/tests/services_hardening_state_host.rs
@@ -0,0 +1,187 @@
+#![forbid(unsafe_code)]
+#![cfg(any(target_os = "linux", target_os = "macos"))]
+
+use std::{error::Error, fs, num::NonZeroU32, os::unix::fs::PermissionsExt, path::Path};
+
+use radroots_service_sqlite::{
+ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceDatabaseMetadata,
+ ServiceSqliteApplicationId, ServiceSqlitePaths,
+};
+use radroots_storage::event::SourceGeneration;
+use rhi::{
+ RHI_STATE_SCHEMA_VERSION, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform,
+ RhiStateHostErrorKind, RhiStateHostMode, initialize_rhi_state, open_rhi_state_inspection,
+ open_rhi_state_read_write, parse_rhi_cli_v1_from, resolve_rhi_runtime_context,
+};
+
+const HOST_SOURCE: &str = include_str!("../src/state_host.rs");
+const LIB_SOURCE: &str = include_str!("../src/lib.rs");
+
+fn runtime(root: &Path, instance: &str) -> rhi::RhiRuntimeContext {
+ let root = root.to_str().expect("UTF-8 temporary root");
+ let invocation = parse_rhi_cli_v1_from([
+ "rhi",
+ "--profile",
+ "repo-local",
+ "--instance",
+ instance,
+ "--repo-local-root",
+ root,
+ "run",
+ ])
+ .expect("valid test invocation");
+ resolve_rhi_runtime_context(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ &invocation,
+ )
+ .expect("runtime context")
+}
+
+fn prepare_state_directory(runtime: &rhi::RhiRuntimeContext) {
+ 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: &rhi::RhiRuntimeContext) -> ServiceDatabaseMetadata {
+ let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()).expect("SQLite paths");
+ ServiceDatabaseMetadata::new(
+ &paths,
+ SourceGeneration::new([0x5a; 32]).expect("generation"),
+ NonZeroU32::new(RHI_STATE_SCHEMA_VERSION).expect("schema version"),
+ 1_725_000_000_000,
+ ServiceSqliteApplicationId::new(0x5248_4931).expect("test application ID"),
+ )
+ .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",
+ "7d7b454b4c9ed86569671993bd03ca868b676665",
+ "rustc-test",
+ "test-target",
+ "service-host",
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ )
+ .expect("build identity");
+ (applied_at, build)
+}
+
+#[tokio::test]
+async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly() {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let runtime = runtime(directory.path(), "primary");
+ prepare_state_directory(&runtime);
+ let metadata = metadata(&runtime);
+ let identity = metadata.identity();
+ let state = runtime.artifacts().state_database();
+ let lock = runtime.artifacts().state_lock();
+
+ assert!(!state.exists());
+ initialize_rhi_state(&runtime, &metadata)
+ .await
+ .expect("create-new initialization");
+ assert!(state.is_file());
+ assert!(lock.is_file());
+ assert_eq!(
+ fs::metadata(state).unwrap().permissions().mode() & 0o777,
+ 0o600
+ );
+ assert_eq!(
+ fs::metadata(lock).unwrap().permissions().mode() & 0o777,
+ 0o600
+ );
+
+ let duplicate = initialize_rhi_state(&runtime, &metadata)
+ .await
+ .expect_err("second initialization must fail");
+ assert_eq!(duplicate.kind(), RhiStateHostErrorKind::Initialize);
+
+ let (applied_at, build) = migration_evidence();
+ let writer = open_rhi_state_read_write(&runtime, &identity, applied_at, &build)
+ .await
+ .expect("existing writable state");
+ assert_eq!(writer.mode(), RhiStateHostMode::ReadWriteExisting);
+ assert_eq!(
+ format!("{writer:?}"),
+ "RhiStateHost { mode: ReadWriteExisting, state: \"[sealed]\" }"
+ );
+
+ let contended = open_rhi_state_inspection(&runtime, &identity)
+ .await
+ .expect_err("inspection must not bypass active writer authority");
+ assert_eq!(contended.kind(), RhiStateHostErrorKind::InspectionOpen);
+ writer.close().await.expect("writer close");
+ writer.close().await.expect("idempotent writer close");
+
+ let inspection = open_rhi_state_inspection(&runtime, &identity)
+ .await
+ .expect("existing inspection state");
+ assert_eq!(inspection.mode(), RhiStateHostMode::ReadOnlyInspection);
+ inspection.close().await.expect("inspection close");
+
+ let writer = open_rhi_state_read_write(&runtime, &identity, applied_at, &build)
+ .await
+ .expect("authority reacquisition after explicit close");
+ writer.close().await.expect("reopened writer close");
+}
+
+#[tokio::test]
+async fn missing_state_and_mismatched_evidence_fail_before_database_creation() {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let primary = runtime(directory.path(), "primary");
+ let secondary = runtime(directory.path(), "secondary");
+ prepare_state_directory(&primary);
+ let primary_metadata = metadata(&primary);
+ let primary_identity = primary_metadata.identity();
+ let (applied_at, build) = migration_evidence();
+
+ let missing = open_rhi_state_read_write(&primary, &primary_identity, applied_at, &build)
+ .await
+ .expect_err("missing state is never created by open");
+ assert_eq!(missing.kind(), RhiStateHostErrorKind::ReadWriteOpen);
+ assert!(!primary.artifacts().state_database().exists());
+
+ let mismatch = initialize_rhi_state(&secondary, &primary_metadata)
+ .await
+ .expect_err("cross-instance metadata");
+ assert_eq!(mismatch.kind(), RhiStateHostErrorKind::InvalidEvidence);
+ assert_eq!(mismatch.code(), "state_evidence_invalid");
+ assert!(Error::source(&mismatch).is_none());
+ let rendered = format!("{mismatch} {mismatch:?}");
+ assert!(!rendered.contains(directory.path().to_string_lossy().as_ref()));
+ assert!(!rendered.contains("state.sqlite"));
+ assert!(!secondary.artifacts().state_database().exists());
+}
+
+#[test]
+fn public_lifecycle_source_is_sealed() {
+ assert!(LIB_SOURCE.contains("mod state_host;"));
+ assert!(!LIB_SOURCE.contains("pub mod state_host;"));
+ assert!(HOST_SOURCE.contains("host: ServiceSqliteHost"));
+ assert!(!HOST_SOURCE.contains("pub host:"));
+ for forbidden in [
+ "pub fn transaction",
+ "pub async fn transaction",
+ "pub fn pool",
+ "pub fn connection",
+ "pub fn into_inner",
+ "pub fn executor",
+ "MigrationDescriptor::",
+ "raw_sql",
+ "CREATE TABLE",
+ "PRAGMA application_id",
+ ] {
+ assert!(
+ !HOST_SOURCE.contains(forbidden),
+ "found forbidden lifecycle authority `{forbidden}`"
+ );
+ }
+}