rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 07480ee68e9f3ee9357527d4c382f1c36e3520bd
parent ded5c32f19d3f8e4ece5c6111bdb3d5238bbe0b3
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 20:31:25 +0000

state: freeze RHI SQLite catalogs

Diffstat:
MREADME | 16++++++++++++++++
Msrc/lib.rs | 7+++++++
Asrc/state_catalog.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/build_policy.rs | 13+++++++++++++
Mtests/package_boundary.rs | 17+++++++++++++++++
Atests/services_hardening_state_catalog.rs | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 379 insertions(+), 0 deletions(-)

diff --git a/README b/README @@ -80,6 +80,22 @@ injected host environment defined by `radroots_runtime_paths`; repo-local uses one explicit absolute base and the same `services/rhi/<instance>` namespace. Path resolution performs no directory creation or filesystem I/O. +## Governed SQLite catalog + +RHI owns one `state.sqlite` per service instance. Its clean-slate baseline is +schema version one and contains only the six shared immutable service-metadata +and append-only migration-ledger objects supplied by `radroots_service_sqlite`. +The future migration catalog is empty at this checkpoint. Exact literal +SHA-256 values bind the migration history, schema-v1 object snapshot, and the +schema catalog that joins those two identities. RHI validates all three before +they can become database authority. + +This catalog layer performs no filesystem or SQLite I/O and owns no pool, +connection, transaction, query, or migration executor. Explicit create-new +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. + Validate the standalone crate through extbuild: ```text diff --git a/src/lib.rs b/src/lib.rs @@ -7,6 +7,7 @@ pub mod features; pub mod host_identity; pub mod identity_storage; mod runtime_context; +mod state_catalog; pub use cli_v1::{ RhiBootstrapProfileV1, RhiCliInvocationV1, RhiCliOutputModeV1, RhiCliV1Error, @@ -29,3 +30,9 @@ pub use runtime_context::{ RhiRuntimeContext, RhiRuntimeContextError, RhiRuntimeContextErrorKind, resolve_rhi_runtime_context, }; +pub use state_catalog::{ + RHI_MIGRATION_CATALOG_SHA256, RHI_STATE_SCHEMA_CATALOG_SHA256, RHI_STATE_SCHEMA_VERSION, + RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_1_SHA256, + RhiStateCatalogError, RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog, + validate_rhi_state_catalogs, +}; diff --git a/src/state_catalog.rs b/src/state_catalog.rs @@ -0,0 +1,155 @@ +//! Immutable RHI schema and migration catalog identity. + +use core::fmt; +use std::error::Error; + +use radroots_service_sqlite::{ + MigrationCatalog, SchemaCatalog, SchemaDigest, SchemaVersionCatalog, +}; + +/// The clean-slate RHI baseline schema version. +pub const RHI_STATE_SCHEMA_VERSION: u32 = 1; + +/// The shared metadata and migration-ledger objects present at schema v1. +pub const RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6; + +/// SHA-256 identity of the empty schema-v1 migration catalog. +pub const RHI_MIGRATION_CATALOG_SHA256: [u8; 32] = [ + 0xec, 0x89, 0xdc, 0x8f, 0x7b, 0x6c, 0x2a, 0x11, 0xb9, 0x67, 0xe3, 0x38, 0x08, 0xe4, 0x03, 0x1e, + 0x29, 0xb3, 0x97, 0x0f, 0xfe, 0xe4, 0x95, 0x9b, 0xff, 0x9b, 0xad, 0x35, 0x28, 0x77, 0xee, 0x9b, +]; + +/// SHA-256 identity of the exact schema-v1 object snapshot. +pub const RHI_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] = [ + 0x94, 0xdc, 0x66, 0xfb, 0xca, 0x60, 0x16, 0x79, 0x61, 0x5c, 0x05, 0x52, 0x29, 0xdc, 0x0d, 0xb6, + 0x11, 0x9f, 0x5b, 0xd9, 0x2b, 0x04, 0x39, 0x0c, 0x67, 0xf6, 0x98, 0xa0, 0x36, 0xfa, 0x78, 0xae, +]; + +/// SHA-256 identity of the schema catalog bound to the migration catalog. +pub const RHI_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [ + 0x23, 0x09, 0x15, 0x3f, 0x3b, 0x49, 0x75, 0x48, 0x87, 0xc5, 0x48, 0xa7, 0x45, 0x9b, 0x3e, 0x09, + 0x09, 0x9c, 0x60, 0xf7, 0x14, 0x6b, 0x37, 0x3c, 0x8f, 0x96, 0x70, 0x6c, 0x67, 0x68, 0xd7, 0x91, +]; + +/// Stable classes for invalid embedded RHI catalog definitions. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiStateCatalogErrorKind { + MigrationCatalog, + SchemaCatalog, + CatalogMismatch, +} + +impl RhiStateCatalogErrorKind { + /// Returns the stable machine-readable classification. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::MigrationCatalog => "migration_catalog_invalid", + Self::SchemaCatalog => "schema_catalog_invalid", + Self::CatalogMismatch => "state_catalog_mismatch", + } + } +} + +/// Source-free failure to construct or validate the embedded RHI catalogs. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiStateCatalogError { + kind: RhiStateCatalogErrorKind, +} + +impl RhiStateCatalogError { + const fn new(kind: RhiStateCatalogErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> RhiStateCatalogErrorKind { + self.kind + } + + /// Returns the stable machine-readable code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Display for RhiStateCatalogError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiStateCatalogErrorKind::MigrationCatalog => { + "RHI migration catalog definition is invalid" + } + RhiStateCatalogErrorKind::SchemaCatalog => "RHI schema catalog definition is invalid", + RhiStateCatalogErrorKind::CatalogMismatch => { + "RHI state catalogs do not match the governed identity" + } + }) + } +} + +impl fmt::Debug for RhiStateCatalogError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiStateCatalogError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiStateCatalogError {} + +/// Constructs the exact schema-v1 migration catalog. +pub fn rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError> { + let catalog = MigrationCatalog::new([]) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?; + if catalog.current_version() != RHI_STATE_SCHEMA_VERSION + || !catalog.descriptors().is_empty() + || catalog.digest().as_bytes() != &RHI_MIGRATION_CATALOG_SHA256 + { + return Err(RhiStateCatalogError::new( + RhiStateCatalogErrorKind::CatalogMismatch, + )); + } + Ok(catalog) +} + +/// Constructs the exact RHI schema catalog bound to the migration catalog. +pub fn rhi_schema_catalog() -> Result<SchemaCatalog, RhiStateCatalogError> { + let migrations = rhi_migration_catalog()?; + let version = SchemaVersionCatalog::new( + RHI_STATE_SCHEMA_VERSION, + [], + SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_1_SHA256), + ) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; + let catalog = SchemaCatalog::new(&migrations, [version]) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; + validate_rhi_state_catalogs(&migrations, &catalog)?; + Ok(catalog) +} + +/// Independently validates exact catalog versions, counts, and digests. +pub fn validate_rhi_state_catalogs( + migrations: &MigrationCatalog, + schema: &SchemaCatalog, +) -> Result<(), RhiStateCatalogError> { + let versions = schema.versions(); + let valid = migrations.current_version() == RHI_STATE_SCHEMA_VERSION + && migrations.descriptors().is_empty() + && migrations.digest().as_bytes() == &RHI_MIGRATION_CATALOG_SHA256 + && schema.migration_catalog_digest() == migrations.digest() + && versions.len() == 1 + && versions[0].version() == RHI_STATE_SCHEMA_VERSION + && versions[0].object_count() == RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT + && versions[0].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_1_SHA256 + && schema.digest().as_bytes() == &RHI_STATE_SCHEMA_CATALOG_SHA256; + if valid { + Ok(()) + } else { + Err(RhiStateCatalogError::new( + RhiStateCatalogErrorKind::CatalogMismatch, + )) + } +} diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -49,6 +49,19 @@ fn shared_host_packages_are_exactly_source_locked() { } #[test] +fn shared_service_sqlite_is_the_only_catalog_authority() { + assert!(MANIFEST.contains( + "radroots_service_sqlite = { git = \"https://github.com/radrootslabs/lib\", rev = \"7d7b454b4c9ed86569671993bd03ca868b676665\", version = \"=0.1.0-alpha\" }" + )); + for forbidden in ["rusqlite", "libsqlite3-sys"] { + assert!( + !MANIFEST.contains(forbidden), + "RHI must not introduce alternate SQLite authority `{forbidden}`" + ); + } +} + +#[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/package_boundary.rs b/tests/package_boundary.rs @@ -42,6 +42,23 @@ fn shared_host_implementations_do_not_escape_the_public_api() { } #[test] +fn state_catalog_module_is_private_and_root_api_is_curated() { + assert!(ROOT.contains("mod state_catalog;")); + assert!(!ROOT.contains("pub mod state_catalog;")); + for required in [ + "rhi_migration_catalog", + "rhi_schema_catalog", + "validate_rhi_state_catalogs", + "RhiStateCatalogError", + ] { + assert!( + ROOT.contains(required), + "RHI root API is missing {required}" + ); + } +} + +#[test] fn human_verification_contract_is_extbuild_only_through_rcld_170() { for required in [ "cargo extbuild doctor", diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs @@ -0,0 +1,171 @@ +#![forbid(unsafe_code)] + +use std::error::Error; + +use radroots_service_sqlite::{ + MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaObject, + SchemaObjectKind, SchemaVersionCatalog, +}; +use rhi::{ + RHI_MIGRATION_CATALOG_SHA256, RHI_STATE_SCHEMA_CATALOG_SHA256, RHI_STATE_SCHEMA_VERSION, + RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_1_SHA256, + RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog, + validate_rhi_state_catalogs, +}; + +const CATALOG_SOURCE: &str = include_str!("../src/state_catalog.rs"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const MANIFEST: &str = include_str!("../Cargo.toml"); + +#[test] +fn schema_v1_and_empty_migration_catalog_have_exact_literal_identities() { + let migrations = rhi_migration_catalog().expect("RHI migration catalog"); + let schema = rhi_schema_catalog().expect("RHI schema catalog"); + + assert_eq!(RHI_STATE_SCHEMA_VERSION, 1); + assert!(migrations.descriptors().is_empty()); + assert_eq!(migrations.current_version(), 1); + assert_eq!( + migrations.digest().as_bytes(), + &RHI_MIGRATION_CATALOG_SHA256 + ); + + assert_eq!(schema.versions().len(), 1); + let version = schema.versions()[0]; + assert_eq!(version.version(), 1); + assert_eq!( + version.object_count(), + RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT + ); + assert_eq!(version.object_count(), 6); + assert_eq!( + version.digest().as_bytes(), + &RHI_STATE_SCHEMA_VERSION_1_SHA256 + ); + assert_eq!(schema.digest().as_bytes(), &RHI_STATE_SCHEMA_CATALOG_SHA256); + assert_eq!(schema.migration_catalog_digest(), migrations.digest()); + validate_rhi_state_catalogs(&migrations, &schema).expect("exact catalogs"); + + assert_eq!( + lower_hex(&RHI_MIGRATION_CATALOG_SHA256), + "ec89dc8f7b6c2a11b967e33808e4031e29b3970ffee4959bff9bad352877ee9b" + ); + assert_eq!( + lower_hex(&RHI_STATE_SCHEMA_VERSION_1_SHA256), + "94dc66fbca601679615c055229dc0db6119f5bd92b04390c67f698a036fa78ae" + ); + assert_eq!( + lower_hex(&RHI_STATE_SCHEMA_CATALOG_SHA256), + "2309153f3b49754887c548a7459b3e09099c60f7146b373c8f96706c6768d791" + ); +} + +#[test] +fn independent_validator_rejects_migration_or_schema_drift() { + const SQL: &str = "CREATE TABLE unexpected (value INTEGER NOT NULL) STRICT"; + let migration = + MigrationDescriptor::sql(2, "unexpected_schema", SQL, MigrationChecksum::for_sql(SQL)) + .expect("valid drift fixture"); + let migrations = MigrationCatalog::new([migration]).expect("drift migration catalog"); + let expected_schema = rhi_schema_catalog().expect("expected schema"); + assert_eq!( + validate_rhi_state_catalogs(&migrations, &expected_schema) + .expect_err("migration drift") + .kind(), + RhiStateCatalogErrorKind::CatalogMismatch + ); + + let empty_migrations = rhi_migration_catalog().expect("empty migrations"); + let object_digest = + SchemaObject::computed_digest(SchemaObjectKind::Table, "unexpected", "unexpected", SQL) + .expect("object digest"); + let object = SchemaObject::new( + SchemaObjectKind::Table, + "unexpected", + "unexpected", + SQL, + object_digest, + ) + .expect("schema object"); + let snapshot_digest = + SchemaVersionCatalog::computed_digest(1, [object.clone()]).expect("snapshot digest"); + let version = SchemaVersionCatalog::new(1, [object], snapshot_digest).expect("version"); + let schema = SchemaCatalog::new(&empty_migrations, [version]).expect("drift schema catalog"); + assert_eq!( + validate_rhi_state_catalogs(&empty_migrations, &schema) + .expect_err("schema drift") + .kind(), + RhiStateCatalogErrorKind::CatalogMismatch + ); +} + +#[test] +fn catalog_errors_are_stable_source_free_and_redacted() { + let migrations = rhi_migration_catalog().expect("migration catalog"); + let object_digest = SchemaObject::computed_digest( + SchemaObjectKind::Table, + "secret_table", + "secret_table", + "secret SQL text", + ) + .expect("object digest"); + let object = SchemaObject::new( + SchemaObjectKind::Table, + "secret_table", + "secret_table", + "secret SQL text", + object_digest, + ) + .expect("object"); + let snapshot = + SchemaVersionCatalog::computed_digest(1, [object.clone()]).expect("snapshot digest"); + let version = SchemaVersionCatalog::new(1, [object], snapshot).expect("version"); + let schema = SchemaCatalog::new(&migrations, [version]).expect("schema catalog"); + let error = validate_rhi_state_catalogs(&migrations, &schema).expect_err("mismatch"); + + assert_eq!(error.kind(), RhiStateCatalogErrorKind::CatalogMismatch); + assert_eq!(error.code(), "state_catalog_mismatch"); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains("secret")); + assert!(!rendered.contains(&lower_hex(snapshot.as_bytes()))); +} + +#[test] +fn catalog_source_is_pure_pinned_and_uses_only_the_shared_authority() { + assert!(MANIFEST.contains( + "radroots_service_sqlite = { git = \"https://github.com/radrootslabs/lib\", rev = \"7d7b454b4c9ed86569671993bd03ca868b676665\", version = \"=0.1.0-alpha\" }" + )); + assert!(LIB_SOURCE.contains("mod state_catalog;")); + assert!(!LIB_SOURCE.contains("pub mod state_catalog;")); + assert!(CATALOG_SOURCE.contains("MigrationCatalog::new([])")); + assert!(CATALOG_SOURCE.contains("SchemaDigest::from_bytes(")); + assert!(!CATALOG_SOURCE.contains("computed_digest")); + for forbidden in [ + "sqlx::", + "rusqlite", + "libsqlite3_sys", + "CREATE TABLE", + "raw_sql", + "std::fs", + "std::path", + "Connection", + "Transaction", + "MigrationDescriptor", + ] { + assert!( + !CATALOG_SOURCE.contains(forbidden), + "found forbidden catalog authority `{forbidden}`" + ); + } +} + +fn lower_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(char::from(DIGITS[usize::from(byte >> 4)])); + output.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + output +}