commit ccee5a9ecd935fe4bd40a30e3d5eb584ef55b715
parent 7978b532627281c711a0d599106078d20f1b54c5
Author: triesap <tyson@radroots.org>
Date: Sun, 23 Aug 2026 23:25:52 +0000
runtime(rhi): establish existing-state foundation
- bind startup to append-only durable configuration evidence
- compose passive readiness with verified existing identity and state
Diffstat:
22 files changed, 2381 insertions(+), 90 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -198,6 +198,15 @@
readiness. Raw pools, connections, or cloneable write authority never escape
typed RHI repositories; live clients mutate only through the Unix admin
boundary and offline state operations must prove that no daemon writer exists.
+- Create-new state begins at the shared schema-v1 baseline and applies the
+ governed RHI schema-v2 configuration-binding migration. Retain at most 1,024
+ consecutive immutable configuration generations containing only normalized
+ config/evidence-policy digests, public identity, exact contract versions,
+ injected apply time, and bounded build identity. Never persist raw TOML,
+ paths, URLs, credential references, or protected identity material. Ordinary
+ startup must use intent-open, discover source generation under retained
+ authority, and match the latest durable binding; configuration apply is an
+ exclusive offline operation.
- Never hold a database transaction while waiting for a source, relay, DNS,
identity provider, clock, entropy, signing, reduction, or backoff.
- Never prune active jobs/outboxes, migration history, current identity/policy
@@ -221,6 +230,12 @@
independently verified encrypted-identity access, with no fallback or
generation. The adapter set owns one private shared `TaskSupervisor` and
exposes no task handle or concrete transport handle.
+- The existing-state runtime foundation must verify durable configuration
+ before credential/identity access and must contact no event source,
+ subscriber, or publication sink. Its passive initial readiness may prove
+ only existing state, durable configuration, and verified identity. Recovery,
+ connectivity, listeners, presence desired state, signals, logging, and the
+ final supervised graph remain with their later owning checkpoints.
- Library code must not install signals, create a runtime, install logging,
call process exit, or detach an authoritative task. Those process authorities
remain exclusively with the final binary checkpoint.
diff --git a/Cargo.toml b/Cargo.toml
@@ -18,7 +18,7 @@ service = "rhi"
host_feature_profile = "service-host"
nix_material = "absent"
config_contract_version = 1
-state_contract_version = 1
+state_contract_version = 2
admin_contract_version = 1
status_contract_version = 1
provider_contract_version = 1
diff --git a/README b/README
@@ -57,6 +57,26 @@ no signal handler, Tokio runtime, logger, or process-exit policy; the final
binary checkpoint owns those authorities. The exact machine contract is
[`runtime_adapters.v1.json`](contracts/services_hardening/runtime_adapters.v1.json).
+## Existing-state runtime foundation
+
+`open_rhi_runtime_foundation` opens only an already initialized database from
+the sealed service-instance intent, discovers its source generation under the
+retained writer authority, verifies the latest append-only configuration
+binding, and then resolves the credential before independently opening the
+encrypted service identity. Missing state is never initialized by ordinary
+startup. No evidence source, live subscription, or publication sink is
+contacted before the durable configuration binding is proven.
+
+The passive readiness snapshot initially proves only existing state, durable
+configuration, and verified identity. Recovery, source connectivity and
+subscription, publication recovery, admin and optional operations listeners,
+and configured presence desired state remain explicitly unsatisfied for their
+later owning checkpoints. Reading the snapshot performs no filesystem,
+SQLite, credential, identity, DNS, or network probe. The foundation installs
+no signals, runtime, logger, or process-exit policy and does not claim the final
+supervised task graph. Its exact machine contract is
+[`runtime_foundation.v1.json`](contracts/services_hardening/runtime_foundation.v1.json).
+
## Hardened v1 configuration contract
The target service configuration is frozen by
@@ -131,19 +151,28 @@ 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.
+RHI owns one `state.sqlite` per service instance. Create-new initialization
+starts from the shared schema-v1 baseline and immediately applies the pinned
+schema-v2 migration. Version two contains the six shared immutable
+service-metadata and migration-ledger objects plus one bounded append-only
+`rhi_config_bindings` table and its three enforcement triggers. Exact literal
+SHA-256 values bind the migration, both schema snapshots, and the schema
+catalog. RHI validates every identity before it can become database authority.
+
+The configuration history retains at most 1,024 consecutive generations. Each
+row stores only normalized configuration and evidence-policy digests, the
+public service identity, exact contract versions, injected apply time, and
+bounded build identity. It never stores raw TOML, paths, relay URLs, credential
+references, or protected identity material. Startup requires the latest row to
+match the admitted document. `apply_rhi_configuration` obtains exclusive
+offline authority, verifies the current binding, appends the candidate
+atomically, treats exact replay idempotently, and explicitly closes state.
+
+Catalog construction itself performs no filesystem or SQLite I/O and owns no
+pool, connection, transaction, query, or migration executor. The sealed state
+host alone executes the governed migration and configuration transactions.
+Service-owned evidence and attestation tables remain reserved for their later
+owning checkpoints.
The sealed RHI state-host lifecycle now reserves and initializes a missing
canonical database only through an explicit create-new operation. Ordinary
diff --git a/contracts/api_baselines/rhi.txt b/contracts/api_baselines/rhi.txt
@@ -44,6 +44,15 @@ pub rhi::RhiCommandV1::Sources(rhi::RhiSourcesCommandV1)
pub rhi::RhiCommandV1::State(rhi::RhiStateCommandV1)
pub rhi::RhiCommandV1::Status
pub rhi::RhiCommandV1::Trade(rhi::RhiTradeCommandV1)
+pub enum rhi::RhiConfigApplyErrorKind
+pub rhi::RhiConfigApplyErrorKind::Binding
+pub rhi::RhiConfigApplyErrorKind::Close
+pub rhi::RhiConfigApplyErrorKind::CommitOutcomeUnknown
+pub rhi::RhiConfigApplyErrorKind::InvalidInput
+pub rhi::RhiConfigApplyErrorKind::ResourceExhausted
+pub rhi::RhiConfigApplyErrorKind::Transaction
+impl rhi::RhiConfigApplyErrorKind
+pub const fn rhi::RhiConfigApplyErrorKind::code(self) -> &'static str
pub enum rhi::RhiConfigCommandV1
pub rhi::RhiConfigCommandV1::Apply
pub rhi::RhiConfigCommandV1::Init
@@ -144,6 +153,41 @@ pub enum rhi::RhiRuntimeContextErrorKind
pub rhi::RhiRuntimeContextErrorKind::InvalidBootstrapBinding
pub rhi::RhiRuntimeContextErrorKind::InvalidServiceIdentity
pub rhi::RhiRuntimeContextErrorKind::PathSelection
+pub enum rhi::RhiRuntimeFoundationErrorKind
+pub rhi::RhiRuntimeFoundationErrorKind::Close
+pub rhi::RhiRuntimeFoundationErrorKind::IdentityAccess
+pub rhi::RhiRuntimeFoundationErrorKind::IdentityBinding
+pub rhi::RhiRuntimeFoundationErrorKind::Readiness
+pub rhi::RhiRuntimeFoundationErrorKind::StateOpen
+pub rhi::RhiRuntimeFoundationErrorKind::TaskFailure
+impl rhi::RhiRuntimeFoundationErrorKind
+pub const fn rhi::RhiRuntimeFoundationErrorKind::code(self) -> &'static str
+pub enum rhi::RhiRuntimePrerequisite
+pub rhi::RhiRuntimePrerequisite::AdminListener
+pub rhi::RhiRuntimePrerequisite::DurableConfiguration
+pub rhi::RhiRuntimePrerequisite::ExistingState
+pub rhi::RhiRuntimePrerequisite::OperationsListener
+pub rhi::RhiRuntimePrerequisite::PresenceDesiredState
+pub rhi::RhiRuntimePrerequisite::PublicationRecovery
+pub rhi::RhiRuntimePrerequisite::ReconciliationRecovery
+pub rhi::RhiRuntimePrerequisite::RequiredSourceConnectivity
+pub rhi::RhiRuntimePrerequisite::RequiredSourceSubscription
+pub rhi::RhiRuntimePrerequisite::VerifiedIdentity
+impl rhi::RhiRuntimePrerequisite
+pub const fn rhi::RhiRuntimePrerequisite::as_str(self) -> &'static str
+pub enum rhi::RhiRuntimeReadinessReason
+pub rhi::RhiRuntimeReadinessReason::AdminListenerUnavailable
+pub rhi::RhiRuntimeReadinessReason::ConfigurationNotDurable
+pub rhi::RhiRuntimeReadinessReason::DatabaseUnavailable
+pub rhi::RhiRuntimeReadinessReason::IdentityUnavailable
+pub rhi::RhiRuntimeReadinessReason::OperationsListenerUnavailable
+pub rhi::RhiRuntimeReadinessReason::PresenceStateUnavailable
+pub rhi::RhiRuntimeReadinessReason::PublicationRecoveryIncomplete
+pub rhi::RhiRuntimeReadinessReason::RecoveryIncomplete
+pub rhi::RhiRuntimeReadinessReason::SourceUnavailable
+pub rhi::RhiRuntimeReadinessReason::SubscriptionInactive
+impl rhi::RhiRuntimeReadinessReason
+pub const fn rhi::RhiRuntimeReadinessReason::as_str(self) -> &'static str
pub enum rhi::RhiSourcesCommandV1
pub rhi::RhiSourcesCommandV1::List
pub enum rhi::RhiStateCatalogErrorKind
@@ -284,6 +328,21 @@ impl core::fmt::Debug for rhi::RhiCliV1Error
pub fn rhi::RhiCliV1Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::fmt::Display for rhi::RhiCliV1Error
pub fn rhi::RhiCliV1Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiConfigApplyError
+impl rhi::RhiConfigApplyError
+pub const fn rhi::RhiConfigApplyError::code(self) -> &'static str
+pub const fn rhi::RhiConfigApplyError::kind(self) -> rhi::RhiConfigApplyErrorKind
+impl core::error::Error for rhi::RhiConfigApplyError
+impl core::fmt::Debug for rhi::RhiConfigApplyError
+pub fn rhi::RhiConfigApplyError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+impl core::fmt::Display for rhi::RhiConfigApplyError
+pub fn rhi::RhiConfigApplyError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiConfigApplyOutcome
+impl rhi::RhiConfigApplyOutcome
+pub const fn rhi::RhiConfigApplyOutcome::changed(self) -> bool
+pub const fn rhi::RhiConfigApplyOutcome::generation(self) -> u16
+impl core::fmt::Debug for rhi::RhiConfigApplyOutcome
+pub fn rhi::RhiConfigApplyOutcome::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiConfigDocumentV1
impl rhi::RhiConfigDocumentV1
pub const fn rhi::RhiConfigDocumentV1::effective(&self) -> &rhi::RhiEffectiveConfigV1
@@ -481,6 +540,32 @@ impl core::fmt::Debug for rhi::RhiRuntimeContextError
pub fn rhi::RhiRuntimeContextError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::fmt::Display for rhi::RhiRuntimeContextError
pub fn rhi::RhiRuntimeContextError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiRuntimeFoundation
+impl rhi::RhiRuntimeFoundation
+pub const fn rhi::RhiRuntimeFoundation::configuration(&self) -> &rhi::RhiConfigDocumentV1
+pub const fn rhi::RhiRuntimeFoundation::metadata(&self) -> &rhi::RhiStateMetadata
+pub const fn rhi::RhiRuntimeFoundation::readiness(&self) -> &rhi::RhiRuntimeReadiness
+pub const fn rhi::RhiRuntimeFoundation::runtime_context(&self) -> &rhi::RhiRuntimeContext
+pub async fn rhi::RhiRuntimeFoundation::shutdown(self) -> core::result::Result<(), rhi::RhiRuntimeFoundationError>
+impl core::fmt::Debug for rhi::RhiRuntimeFoundation
+pub fn rhi::RhiRuntimeFoundation::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiRuntimeFoundationError
+impl rhi::RhiRuntimeFoundationError
+pub const fn rhi::RhiRuntimeFoundationError::code(self) -> &'static str
+pub const fn rhi::RhiRuntimeFoundationError::kind(self) -> rhi::RhiRuntimeFoundationErrorKind
+impl core::error::Error for rhi::RhiRuntimeFoundationError
+impl core::fmt::Debug for rhi::RhiRuntimeFoundationError
+pub fn rhi::RhiRuntimeFoundationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+impl core::fmt::Display for rhi::RhiRuntimeFoundationError
+pub fn rhi::RhiRuntimeFoundationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiRuntimeReadiness
+impl rhi::RhiRuntimeReadiness
+pub fn rhi::RhiRuntimeReadiness::is_ready(&self) -> bool
+pub const fn rhi::RhiRuntimeReadiness::reasons(&self) -> &[rhi::RhiRuntimeReadinessReason]
+pub fn rhi::RhiRuntimeReadiness::required(&self) -> &[rhi::RhiRuntimePrerequisite]
+pub fn rhi::RhiRuntimeReadiness::satisfied(&self) -> &[rhi::RhiRuntimePrerequisite]
+impl core::fmt::Debug for rhi::RhiRuntimeReadiness
+pub fn rhi::RhiRuntimeReadiness::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiRuntimeThreadLimitsV1
impl rhi::RhiRuntimeThreadLimitsV1
pub const fn rhi::RhiRuntimeThreadLimitsV1::blocking_threads(self) -> usize
@@ -562,6 +647,7 @@ pub const fn rhi::RhiStateMetadata::database(&self) -> &radroots_service_sqlite:
pub fn rhi::RhiStateMetadata::database_identity(&self) -> radroots_service_sqlite::metadata::ServiceDatabaseIdentity
pub const fn rhi::RhiStateMetadata::evidence_policy_digest(&self) -> rhi::RhiEvidencePolicyDigest
pub const fn rhi::RhiStateMetadata::expected_identity(&self) -> &rhi::RhiExpectedPublicIdentity
+pub const fn rhi::RhiStateMetadata::initial_database_metadata(&self) -> &radroots_service_sqlite::metadata::ServiceDatabaseMetadata
pub fn rhi::RhiStateMetadata::new(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1, radroots_storage::event::SourceGeneration, u64) -> core::result::Result<Self, rhi::RhiStateMetadataError>
pub const fn rhi::RhiStateMetadata::policy_versions(&self) -> rhi::RhiStatePolicyVersions
impl core::fmt::Debug for rhi::RhiStateMetadata
@@ -692,6 +778,7 @@ pub const rhi::RHI_ADMIN_CONTRACT_VERSION: u32
pub const rhi::RHI_AGREEMENT_ATTESTATION_PROOF_SYSTEM_LOCAL_STATEMENT_HASH: &str
pub const rhi::RHI_AGREEMENT_ATTESTATION_PROTOCOL_ID: &str
pub const rhi::RHI_AGREEMENT_ATTESTATION_REPORT_VERSION: u16
+pub const rhi::RHI_CONFIG_BINDING_MAX_GENERATIONS: u16
pub const rhi::RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES: usize
pub const rhi::RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES: usize
pub const rhi::RHI_CONFIG_SCHEMA: &str
@@ -702,15 +789,20 @@ pub const rhi::RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize
pub const rhi::RHI_MIGRATION_CATALOG_SHA256: [u8; 32]
pub const rhi::RHI_PROVIDER_CONTRACT_VERSION: u32
pub const rhi::RHI_RUNTIME_ADAPTER_CONTRACT_VERSION: u32
+pub const rhi::RHI_RUNTIME_FOUNDATION_CONTRACT_VERSION: u32
pub const rhi::RHI_RUNTIME_JITTER_MAX_ENTROPY_DRAWS: usize
pub const rhi::RHI_RUNTIME_JITTER_MAX_MILLISECONDS: u64
pub const rhi::RHI_STATE_APPLICATION_ID: u32
+pub const rhi::RHI_STATE_BASE_SCHEMA_VERSION: u32
pub const rhi::RHI_STATE_REPOSITORY_CONTRACT_VERSION: u32
pub const rhi::RHI_STATE_REPOSITORY_COUNT: usize
pub const rhi::RHI_STATE_SCHEMA_CATALOG_SHA256: [u8; 32]
pub const rhi::RHI_STATE_SCHEMA_VERSION: u32
pub const rhi::RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32
pub const rhi::RHI_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32]
+pub const rhi::RHI_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256: [u8; 32]
+pub const rhi::RHI_STATE_SCHEMA_VERSION_2_OBJECT_COUNT: u32
+pub const rhi::RHI_STATE_SCHEMA_VERSION_2_SHA256: [u8; 32]
pub const rhi::RHI_STATUS_CONTRACT_VERSION: u32
pub const rhi::RHI_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize
pub const rhi::RHI_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32
@@ -722,12 +814,15 @@ pub trait rhi::RhiIdentityAccess: core::marker::Send + core::marker::Sync
pub fn rhi::RhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError>
impl rhi::RhiIdentityAccess for rhi::CanonicalRhiIdentityAccess
pub fn rhi::CanonicalRhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError>
+pub async fn rhi::apply_rhi_configuration(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1, &rhi::RhiConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiConfigApplyOutcome, rhi::RhiConfigApplyError>
pub fn rhi::attest_projection_claim(&radroots_trade::trade_contract_v1::RadrootsTradeProjectionV1, &radroots_event::id::MutationId, &rhi::TradeAgreementAttestationPolicy) -> core::result::Result<rhi::TradeAgreementAttestationReportV1, rhi::TradeAgreementAttestationError>
pub async fn rhi::finalize_rhi_state_restore(rhi::RhiStagedStateRestore) -> core::result::Result<(), rhi::RhiStateMaintenanceError>
pub async fn rhi::initialize_rhi_state(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<(), rhi::RhiStateHostError>
pub fn rhi::open_rhi_encrypted_identity(&rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError>
+pub async fn rhi::open_rhi_runtime_foundation(rhi::RhiRuntimeContext, rhi::RhiConfigDocumentV1, rhi::RhiRuntimeAdapters, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiRuntimeFoundation, rhi::RhiRuntimeFoundationError>
pub async fn rhi::open_rhi_state_inspection(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata) -> core::result::Result<rhi::RhiStateHost, rhi::RhiStateHostError>
pub async fn rhi::open_rhi_state_read_write(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiStateHost, rhi::RhiStateHostError>
+pub async fn rhi::open_rhi_state_read_write_from_config(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiStateHost, rhi::RhiStateHostError>
pub fn rhi::parse_rhi_cli_v1_from<I, T>(I) -> core::result::Result<rhi::RhiCliInvocationV1, rhi::RhiCliV1Error> where I: core::iter::traits::collect::IntoIterator<Item = T>, T: core::convert::Into<std::ffi::os_str::OsString> + core::clone::Clone
pub fn rhi::parse_rhi_config_v1(&[u8], rhi::RhiConfigProfile) -> core::result::Result<rhi::RhiConfigDocumentV1, rhi::RhiConfigV1Error>
pub fn rhi::provision_rhi_encrypted_identity(&rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential, rhi::RhiEncryptedIdentityProvisioningMaterial) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError>
diff --git a/contracts/services_hardening/runtime_foundation.v1.json b/contracts/services_hardening/runtime_foundation.v1.json
@@ -0,0 +1,47 @@
+{
+ "schema": "radroots.rhi.runtime-foundation",
+ "schema_version": 1,
+ "contract_version": 1,
+ "state_open": {
+ "mode": "read_write_existing",
+ "intent_discovers_source_generation": true,
+ "initialize_if_missing": false,
+ "durable_configuration_binding_required": true,
+ "raw_sqlite_authority_exposed": false
+ },
+ "identity_startup": {
+ "order": ["credential", "encrypted_identity"],
+ "existing_only": true,
+ "independently_verified": true,
+ "protected_values_exposed": false
+ },
+ "transport": {
+ "invoked_during_foundation": false,
+ "source_contact_before_durable_configuration": false,
+ "publication_before_durable_configuration": false
+ },
+ "initial_satisfaction": [
+ "existing_state",
+ "durable_configuration",
+ "verified_identity"
+ ],
+ "deferred": [
+ "reconciliation_recovery",
+ "source_connectivity",
+ "source_subscription",
+ "publication_recovery",
+ "admin_listener",
+ "operations_listener",
+ "presence_desired_state",
+ "signals",
+ "logging",
+ "final_supervised_task_graph"
+ ],
+ "task_ownership": {
+ "shared_supervisor": "radroots_service_host::TaskSupervisor",
+ "task_handles_exposed": false,
+ "library_runtime_creation": false,
+ "signal_installation": false,
+ "process_exit": false
+ }
+}
diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml
@@ -16,7 +16,7 @@ material = "absent"
[contract_versions]
config = 1
-state = 1
+state = 2
admin = 1
status = 1
provider = 1
diff --git a/src/lib.rs b/src/lib.rs
@@ -10,7 +10,9 @@ mod identity_credential;
mod identity_envelope;
mod runtime_adapters;
mod runtime_context;
+mod runtime_foundation;
mod state_catalog;
+mod state_config;
mod state_host;
mod state_maintenance;
mod state_metadata;
@@ -71,15 +73,27 @@ pub use runtime_context::{
RhiRuntimeContext, RhiRuntimeContextError, RhiRuntimeContextErrorKind,
resolve_rhi_runtime_context,
};
+pub use runtime_foundation::{
+ RHI_RUNTIME_FOUNDATION_CONTRACT_VERSION, RhiRuntimeFoundation, RhiRuntimeFoundationError,
+ RhiRuntimeFoundationErrorKind, RhiRuntimePrerequisite, RhiRuntimeReadiness,
+ RhiRuntimeReadinessReason, open_rhi_runtime_foundation,
+};
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,
+ RHI_MIGRATION_CATALOG_SHA256, RHI_STATE_BASE_SCHEMA_VERSION, RHI_STATE_SCHEMA_CATALOG_SHA256,
+ RHI_STATE_SCHEMA_VERSION, RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT,
+ RHI_STATE_SCHEMA_VERSION_1_SHA256, RHI_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256,
+ RHI_STATE_SCHEMA_VERSION_2_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_2_SHA256,
RhiStateCatalogError, RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog,
validate_rhi_state_catalogs,
};
+pub use state_config::{
+ RHI_CONFIG_BINDING_MAX_GENERATIONS, RhiConfigApplyError, RhiConfigApplyErrorKind,
+ RhiConfigApplyOutcome,
+};
pub use state_host::{
- RhiStateHost, RhiStateHostError, RhiStateHostErrorKind, RhiStateHostMode, initialize_rhi_state,
- open_rhi_state_inspection, open_rhi_state_read_write,
+ RhiStateHost, RhiStateHostError, RhiStateHostErrorKind, RhiStateHostMode,
+ apply_rhi_configuration, initialize_rhi_state, open_rhi_state_inspection,
+ open_rhi_state_read_write, open_rhi_state_read_write_from_config,
};
pub use state_maintenance::{
RhiStagedStateRestore, RhiStateMaintenanceError, RhiStateMaintenanceErrorKind,
diff --git a/src/runtime_adapters.rs b/src/runtime_adapters.rs
@@ -412,6 +412,15 @@ impl RhiRuntimeAdapters {
self.supervisor.task_count()
}
+ pub(crate) async fn shutdown(&mut self) -> Result<(), ()> {
+ self.supervisor.request_cancellation();
+ self.supervisor
+ .supervise()
+ .await
+ .map(|_| ())
+ .map_err(|_| ())
+ }
+
#[cfg(test)]
pub(crate) fn supervisor_mut(&mut self) -> &mut TaskSupervisor {
&mut self.supervisor
diff --git a/src/runtime_foundation.rs b/src/runtime_foundation.rs
@@ -0,0 +1,427 @@
+//! Existing-state-only RHI runtime foundation.
+
+use core::fmt;
+use std::error::Error;
+
+use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity};
+
+use crate::{
+ RhiConfigDocumentV1, RhiDecryptedIdentity, RhiIdentityEnvelopeBinding, RhiRuntimeAdapters,
+ RhiRuntimeContext, RhiStateHost, RhiStateMetadata, open_rhi_state_read_write_from_config,
+};
+
+#[cfg(test)]
+const RUNTIME_FOUNDATION_CONTRACT: &str =
+ include_str!("../contracts/services_hardening/runtime_foundation.v1.json");
+
+/// Exact version of the RHI runtime-foundation contract.
+pub const RHI_RUNTIME_FOUNDATION_CONTRACT_VERSION: u32 = 1;
+
+/// Closed startup conditions required before the RHI service may be ready.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum RhiRuntimePrerequisite {
+ ExistingState,
+ DurableConfiguration,
+ VerifiedIdentity,
+ ReconciliationRecovery,
+ RequiredSourceConnectivity,
+ RequiredSourceSubscription,
+ PublicationRecovery,
+ AdminListener,
+ OperationsListener,
+ PresenceDesiredState,
+}
+
+impl RhiRuntimePrerequisite {
+ /// Returns the exact machine-contract spelling.
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::ExistingState => "existing_state",
+ Self::DurableConfiguration => "durable_configuration",
+ Self::VerifiedIdentity => "verified_identity",
+ Self::ReconciliationRecovery => "reconciliation_recovery",
+ Self::RequiredSourceConnectivity => "required_source_connectivity",
+ Self::RequiredSourceSubscription => "required_source_subscription",
+ Self::PublicationRecovery => "publication_recovery",
+ Self::AdminListener => "admin_listener",
+ Self::OperationsListener => "operations_listener",
+ Self::PresenceDesiredState => "presence_desired_state",
+ }
+ }
+
+ const fn reason(self) -> RhiRuntimeReadinessReason {
+ match self {
+ Self::ExistingState => RhiRuntimeReadinessReason::DatabaseUnavailable,
+ Self::DurableConfiguration => RhiRuntimeReadinessReason::ConfigurationNotDurable,
+ Self::VerifiedIdentity => RhiRuntimeReadinessReason::IdentityUnavailable,
+ Self::ReconciliationRecovery => RhiRuntimeReadinessReason::RecoveryIncomplete,
+ Self::RequiredSourceConnectivity => RhiRuntimeReadinessReason::SourceUnavailable,
+ Self::RequiredSourceSubscription => RhiRuntimeReadinessReason::SubscriptionInactive,
+ Self::PublicationRecovery => RhiRuntimeReadinessReason::PublicationRecoveryIncomplete,
+ Self::AdminListener => RhiRuntimeReadinessReason::AdminListenerUnavailable,
+ Self::OperationsListener => RhiRuntimeReadinessReason::OperationsListenerUnavailable,
+ Self::PresenceDesiredState => RhiRuntimeReadinessReason::PresenceStateUnavailable,
+ }
+ }
+}
+
+/// Closed stable reason vocabulary for an unsatisfied prerequisite.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum RhiRuntimeReadinessReason {
+ AdminListenerUnavailable,
+ ConfigurationNotDurable,
+ DatabaseUnavailable,
+ IdentityUnavailable,
+ OperationsListenerUnavailable,
+ PresenceStateUnavailable,
+ PublicationRecoveryIncomplete,
+ RecoveryIncomplete,
+ SourceUnavailable,
+ SubscriptionInactive,
+}
+
+impl RhiRuntimeReadinessReason {
+ /// Returns the exact machine-contract spelling.
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::AdminListenerUnavailable => "admin_listener_unavailable",
+ Self::ConfigurationNotDurable => "configuration_not_durable",
+ Self::DatabaseUnavailable => "database_unavailable",
+ Self::IdentityUnavailable => "identity_unavailable",
+ Self::OperationsListenerUnavailable => "operations_listener_unavailable",
+ Self::PresenceStateUnavailable => "presence_state_unavailable",
+ Self::PublicationRecoveryIncomplete => "publication_recovery_incomplete",
+ Self::RecoveryIncomplete => "recovery_incomplete",
+ Self::SourceUnavailable => "source_unavailable",
+ Self::SubscriptionInactive => "subscription_inactive",
+ }
+ }
+}
+
+/// Immutable passive readiness evidence derived at startup.
+#[derive(Clone, PartialEq, Eq)]
+pub struct RhiRuntimeReadiness {
+ required: Box<[RhiRuntimePrerequisite]>,
+ satisfied: Box<[RhiRuntimePrerequisite]>,
+ reasons: Box<[RhiRuntimeReadinessReason]>,
+}
+
+impl RhiRuntimeReadiness {
+ /// Returns true only after every exact prerequisite is satisfied.
+ #[must_use]
+ pub fn is_ready(&self) -> bool {
+ self.required.len() == self.satisfied.len()
+ && self
+ .required
+ .iter()
+ .all(|required| self.satisfied.contains(required))
+ }
+
+ /// Returns the exact ordered prerequisite inventory.
+ #[must_use]
+ pub fn required(&self) -> &[RhiRuntimePrerequisite] {
+ &self.required
+ }
+
+ /// Returns the exact ordered prerequisites already proven.
+ #[must_use]
+ pub fn satisfied(&self) -> &[RhiRuntimePrerequisite] {
+ &self.satisfied
+ }
+
+ /// Returns bounded stable reasons for missing prerequisites.
+ #[must_use]
+ pub const fn reasons(&self) -> &[RhiRuntimeReadinessReason] {
+ &self.reasons
+ }
+}
+
+impl fmt::Debug for RhiRuntimeReadiness {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiRuntimeReadiness")
+ .field("ready", &self.is_ready())
+ .field("required", &self.required)
+ .field("satisfied", &self.satisfied)
+ .field("reasons", &self.reasons)
+ .finish()
+ }
+}
+
+/// Stable source-free runtime-foundation failure class.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiRuntimeFoundationErrorKind {
+ StateOpen,
+ IdentityBinding,
+ IdentityAccess,
+ Readiness,
+ TaskFailure,
+ Close,
+}
+
+impl RhiRuntimeFoundationErrorKind {
+ /// Returns the stable machine-facing safe code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::StateOpen => "runtime_state_open_failed",
+ Self::IdentityBinding => "runtime_identity_binding_invalid",
+ Self::IdentityAccess => "runtime_identity_access_failed",
+ Self::Readiness => "runtime_readiness_invalid",
+ Self::TaskFailure => "runtime_task_failed",
+ Self::Close => "runtime_close_failed",
+ }
+ }
+}
+
+/// One redacted source-free runtime-foundation failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct RhiRuntimeFoundationError {
+ kind: RhiRuntimeFoundationErrorKind,
+}
+
+impl RhiRuntimeFoundationError {
+ const fn new(kind: RhiRuntimeFoundationErrorKind) -> Self {
+ Self { kind }
+ }
+
+ /// Returns the stable failure kind.
+ #[must_use]
+ pub const fn kind(self) -> RhiRuntimeFoundationErrorKind {
+ self.kind
+ }
+
+ /// Returns the stable machine-facing safe code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ self.kind.code()
+ }
+}
+
+impl fmt::Debug for RhiRuntimeFoundationError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiRuntimeFoundationError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl fmt::Display for RhiRuntimeFoundationError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ RhiRuntimeFoundationErrorKind::StateOpen => "RHI existing state could not be opened",
+ RhiRuntimeFoundationErrorKind::IdentityBinding => "RHI identity binding is invalid",
+ RhiRuntimeFoundationErrorKind::IdentityAccess => "RHI identity startup failed",
+ RhiRuntimeFoundationErrorKind::Readiness => "RHI readiness prerequisites are invalid",
+ RhiRuntimeFoundationErrorKind::TaskFailure => "RHI supervised task failed",
+ RhiRuntimeFoundationErrorKind::Close => "RHI runtime foundation could not close",
+ })
+ }
+}
+
+impl Error for RhiRuntimeFoundationError {}
+
+/// Existing-only RHI foundation with sealed state, identity, adapters, and task ownership.
+#[must_use = "the runtime foundation must be shut down so state and tasks are joined"]
+pub struct RhiRuntimeFoundation {
+ runtime: RhiRuntimeContext,
+ configuration: RhiConfigDocumentV1,
+ metadata: RhiStateMetadata,
+ state: RhiStateHost,
+ _identity: RhiDecryptedIdentity,
+ adapters: RhiRuntimeAdapters,
+ readiness: RhiRuntimeReadiness,
+}
+
+impl RhiRuntimeFoundation {
+ /// Returns the immutable canonical instance context.
+ #[must_use]
+ pub const fn runtime_context(&self) -> &RhiRuntimeContext {
+ &self.runtime
+ }
+
+ /// Returns the admitted immutable configuration.
+ #[must_use]
+ pub const fn configuration(&self) -> &RhiConfigDocumentV1 {
+ &self.configuration
+ }
+
+ /// Returns metadata discovered and proven under retained state authority.
+ #[must_use]
+ pub const fn metadata(&self) -> &RhiStateMetadata {
+ &self.metadata
+ }
+
+ /// Returns passive startup-readiness evidence without performing I/O.
+ #[must_use]
+ pub const fn readiness(&self) -> &RhiRuntimeReadiness {
+ &self.readiness
+ }
+
+ /// Requests cancellation, joins owned tasks, and explicitly closes state.
+ pub async fn shutdown(mut self) -> Result<(), RhiRuntimeFoundationError> {
+ let supervised = self.adapters.shutdown().await;
+ let closed = self.state.close().await;
+ if supervised.is_err() {
+ Err(RhiRuntimeFoundationError::new(
+ RhiRuntimeFoundationErrorKind::TaskFailure,
+ ))
+ } else if closed.is_err() {
+ Err(RhiRuntimeFoundationError::new(
+ RhiRuntimeFoundationErrorKind::Close,
+ ))
+ } else {
+ Ok(())
+ }
+ }
+}
+
+impl fmt::Debug for RhiRuntimeFoundation {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiRuntimeFoundation")
+ .field("runtime", &"[redacted]")
+ .field("configuration", &"[redacted]")
+ .field("metadata", &"[redacted]")
+ .field("state", &"[sealed]")
+ .field("identity", &"[redacted]")
+ .field("adapters", &"[sealed]")
+ .field("readiness", &self.readiness)
+ .finish()
+ }
+}
+
+/// Opens existing state and composes the non-I/O RHI startup foundation.
+///
+/// The durable configuration binding is verified before credential or identity
+/// access. No source, subscription, or publication adapter is invoked, and no
+/// final service task graph, signal handler, logger, runtime, or process-exit
+/// authority is created here.
+pub async fn open_rhi_runtime_foundation(
+ runtime: RhiRuntimeContext,
+ configuration: RhiConfigDocumentV1,
+ adapters: RhiRuntimeAdapters,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+) -> Result<RhiRuntimeFoundation, RhiRuntimeFoundationError> {
+ let state = open_rhi_state_read_write_from_config(&runtime, &configuration, applied_at, build)
+ .await
+ .map_err(|_| RhiRuntimeFoundationError::new(RhiRuntimeFoundationErrorKind::StateOpen))?;
+ let metadata = state.metadata().clone();
+ let binding = match RhiIdentityEnvelopeBinding::from_configuration(&configuration, &metadata) {
+ Ok(binding) => binding,
+ Err(_) => {
+ return Err(close_failure(state, RhiRuntimeFoundationErrorKind::IdentityBinding).await);
+ }
+ };
+ let identity = match adapters
+ .identity_credential()
+ .open_existing(&runtime, &binding)
+ {
+ Ok(identity) => identity,
+ Err(_) => {
+ return Err(close_failure(state, RhiRuntimeFoundationErrorKind::IdentityAccess).await);
+ }
+ };
+ let readiness = match startup_readiness(&configuration) {
+ Ok(readiness) => readiness,
+ Err(error) => return Err(close_failure(state, error.kind()).await),
+ };
+ Ok(RhiRuntimeFoundation {
+ runtime,
+ configuration,
+ metadata,
+ state,
+ _identity: identity,
+ adapters,
+ readiness,
+ })
+}
+
+async fn close_failure(
+ state: RhiStateHost,
+ fallback: RhiRuntimeFoundationErrorKind,
+) -> RhiRuntimeFoundationError {
+ if state.close().await.is_err() {
+ RhiRuntimeFoundationError::new(RhiRuntimeFoundationErrorKind::Close)
+ } else {
+ RhiRuntimeFoundationError::new(fallback)
+ }
+}
+
+fn startup_readiness(
+ configuration: &RhiConfigDocumentV1,
+) -> Result<RhiRuntimeReadiness, RhiRuntimeFoundationError> {
+ let normalized = configuration.normalized();
+ let operations_enabled = normalized
+ .pointer("/operations/enabled")
+ .and_then(serde_json::Value::as_bool)
+ .ok_or_else(|| RhiRuntimeFoundationError::new(RhiRuntimeFoundationErrorKind::Readiness))?;
+ let presence_enabled = normalized
+ .pointer("/presence/enabled")
+ .and_then(serde_json::Value::as_bool)
+ .ok_or_else(|| RhiRuntimeFoundationError::new(RhiRuntimeFoundationErrorKind::Readiness))?;
+ let mut required = vec![
+ RhiRuntimePrerequisite::ExistingState,
+ RhiRuntimePrerequisite::DurableConfiguration,
+ RhiRuntimePrerequisite::VerifiedIdentity,
+ RhiRuntimePrerequisite::ReconciliationRecovery,
+ RhiRuntimePrerequisite::RequiredSourceConnectivity,
+ RhiRuntimePrerequisite::RequiredSourceSubscription,
+ RhiRuntimePrerequisite::PublicationRecovery,
+ RhiRuntimePrerequisite::AdminListener,
+ ];
+ if operations_enabled {
+ required.push(RhiRuntimePrerequisite::OperationsListener);
+ }
+ if presence_enabled {
+ required.push(RhiRuntimePrerequisite::PresenceDesiredState);
+ }
+ let satisfied = vec![
+ RhiRuntimePrerequisite::ExistingState,
+ RhiRuntimePrerequisite::DurableConfiguration,
+ RhiRuntimePrerequisite::VerifiedIdentity,
+ ];
+ let mut reasons = required
+ .iter()
+ .filter(|item| !satisfied.contains(item))
+ .map(|item| item.reason())
+ .collect::<Vec<_>>();
+ reasons.sort_unstable();
+ reasons.dedup();
+ Ok(RhiRuntimeReadiness {
+ required: required.into_boxed_slice(),
+ satisfied: satisfied.into_boxed_slice(),
+ reasons: reasons.into_boxed_slice(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn contract_is_exact_and_errors_are_source_free() {
+ let contract: serde_json::Value =
+ serde_json::from_str(RUNTIME_FOUNDATION_CONTRACT).expect("foundation contract");
+ assert_eq!(contract["schema_version"], 1);
+ assert_eq!(contract["state_open"]["initialize_if_missing"], false);
+ assert_eq!(contract["transport"]["invoked_during_foundation"], false);
+ for kind in [
+ RhiRuntimeFoundationErrorKind::StateOpen,
+ RhiRuntimeFoundationErrorKind::IdentityBinding,
+ RhiRuntimeFoundationErrorKind::IdentityAccess,
+ RhiRuntimeFoundationErrorKind::Readiness,
+ RhiRuntimeFoundationErrorKind::TaskFailure,
+ RhiRuntimeFoundationErrorKind::Close,
+ ] {
+ let error = RhiRuntimeFoundationError::new(kind);
+ assert!(!error.code().is_empty());
+ assert!(!error.to_string().is_empty());
+ assert!(Error::source(&error).is_none());
+ }
+ }
+}
diff --git a/src/state_catalog.rs b/src/state_catalog.rs
@@ -4,19 +4,26 @@ use core::fmt;
use std::error::Error;
use radroots_service_sqlite::{
- MigrationCatalog, SchemaCatalog, SchemaDigest, SchemaVersionCatalog,
+ MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest,
+ SchemaObject, SchemaObjectKind, SchemaVersionCatalog,
};
-/// The clean-slate RHI baseline schema version.
-pub const RHI_STATE_SCHEMA_VERSION: u32 = 1;
+/// The shared create-new baseline written before RHI migrations run.
+pub const RHI_STATE_BASE_SCHEMA_VERSION: u32 = 1;
+
+/// The newest governed RHI state schema understood by this binary.
+pub const RHI_STATE_SCHEMA_VERSION: u32 = 2;
/// 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.
+/// The shared objects plus the bounded append-only RHI configuration history.
+pub const RHI_STATE_SCHEMA_VERSION_2_OBJECT_COUNT: u32 = 10;
+
+/// SHA-256 identity of the ordered migration catalog rooted at schema v1.
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,
+ 0xb6, 0x40, 0xa9, 0x09, 0x5d, 0x53, 0x18, 0xdb, 0xfd, 0x0a, 0xfb, 0xfb, 0xe6, 0xe0, 0x52, 0x81,
+ 0x11, 0x3a, 0x9c, 0xef, 0x45, 0x64, 0x80, 0x5c, 0x02, 0x1c, 0x36, 0x8c, 0x3c, 0x52, 0xfc, 0x08,
];
/// SHA-256 identity of the exact schema-v1 object snapshot.
@@ -25,10 +32,127 @@ pub const RHI_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] = [
0x11, 0x9f, 0x5b, 0xd9, 0x2b, 0x04, 0x39, 0x0c, 0x67, 0xf6, 0x98, 0xa0, 0x36, 0xfa, 0x78, 0xae,
];
+/// SHA-256 identity of the schema-v2 configuration-binding migration.
+pub const RHI_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256: [u8; 32] = [
+ 0xa2, 0xc1, 0xaa, 0x53, 0xf7, 0xfe, 0xee, 0x03, 0x85, 0xe7, 0x74, 0xde, 0x20, 0xe0, 0x72, 0x52,
+ 0x0f, 0x49, 0x26, 0xc5, 0x59, 0xc6, 0x42, 0x1a, 0xab, 0x59, 0x0e, 0x92, 0xba, 0x14, 0x4c, 0x55,
+];
+
+/// SHA-256 identity of the exact schema-v2 object snapshot.
+pub const RHI_STATE_SCHEMA_VERSION_2_SHA256: [u8; 32] = [
+ 0xbc, 0xcb, 0xf1, 0xe6, 0x2f, 0xe7, 0x64, 0x4c, 0x9c, 0x37, 0x72, 0x05, 0xb2, 0x5a, 0x92, 0x29,
+ 0x8e, 0x08, 0x8b, 0x8c, 0x26, 0xd1, 0x5b, 0xa4, 0x51, 0x33, 0xca, 0x5e, 0x9b, 0x73, 0x15, 0xa9,
+];
+
/// 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,
+ 0x1b, 0x7f, 0x73, 0x59, 0xb6, 0x2e, 0xd7, 0xdc, 0xd4, 0x76, 0x28, 0x9e, 0x46, 0x69, 0x3d, 0x9b,
+ 0x3d, 0x13, 0x69, 0xdc, 0xaf, 0x7d, 0x59, 0x52, 0xf0, 0x32, 0x9a, 0x5c, 0x86, 0xf8, 0xb8, 0x5f,
+];
+
+macro_rules! rhi_config_bindings_table_sql {
+ () => {
+ r#"CREATE TABLE rhi_config_bindings (
+ generation INTEGER NOT NULL PRIMARY KEY CHECK (generation BETWEEN 1 AND 1024),
+ normalized_config_sha256 BLOB NOT NULL CHECK (length(normalized_config_sha256) = 32),
+ evidence_policy_sha256 BLOB NOT NULL CHECK (length(evidence_policy_sha256) = 32),
+ service_public_key TEXT NOT NULL
+ CHECK (length(CAST(service_public_key AS BLOB)) = 64)
+ CHECK (service_public_key NOT GLOB '*[^0-9a-f]*'),
+ config_contract_version INTEGER NOT NULL
+ CHECK (config_contract_version BETWEEN 1 AND 4294967295),
+ state_contract_version INTEGER NOT NULL
+ CHECK (state_contract_version BETWEEN 1 AND 4294967295),
+ admin_contract_version INTEGER NOT NULL
+ CHECK (admin_contract_version BETWEEN 1 AND 4294967295),
+ status_contract_version INTEGER NOT NULL
+ CHECK (status_contract_version BETWEEN 1 AND 4294967295),
+ provider_contract_version INTEGER NOT NULL
+ CHECK (provider_contract_version BETWEEN 1 AND 4294967295),
+ applied_at_unix_s INTEGER NOT NULL
+ CHECK (applied_at_unix_s BETWEEN 0 AND 9223372036854775807),
+ service_version TEXT NOT NULL
+ CHECK (length(CAST(service_version AS BLOB)) BETWEEN 1 AND 128),
+ service_commit TEXT NOT NULL
+ CHECK (length(CAST(service_commit AS BLOB)) = 40),
+ lib_revision TEXT NOT NULL
+ CHECK (length(CAST(lib_revision AS BLOB)) = 40),
+ rust_version TEXT NOT NULL
+ CHECK (length(CAST(rust_version AS BLOB)) BETWEEN 1 AND 128),
+ target TEXT NOT NULL CHECK (length(CAST(target AS BLOB)) BETWEEN 1 AND 128),
+ feature_profile TEXT NOT NULL
+ CHECK (length(CAST(feature_profile AS BLOB)) BETWEEN 1 AND 128)
+) STRICT"#
+ };
+}
+
+macro_rules! rhi_config_bindings_guard_insert_sql {
+ () => {
+ r#"CREATE TRIGGER rhi_config_bindings_guard_insert
+BEFORE INSERT ON rhi_config_bindings
+WHEN NEW.generation != COALESCE(
+ (SELECT MAX(generation) + 1 FROM rhi_config_bindings), 1
+ )
+ OR (SELECT COUNT(*) FROM rhi_config_bindings) >= 1024
+ OR NEW.applied_at_unix_s < COALESCE(
+ (SELECT MAX(applied_at_unix_s) FROM rhi_config_bindings), 0
+ )
+BEGIN
+ SELECT RAISE(ABORT, 'configuration binding sequence is invalid');
+END"#
+ };
+}
+
+macro_rules! rhi_config_bindings_no_update_sql {
+ () => {
+ r#"CREATE TRIGGER rhi_config_bindings_no_update
+BEFORE UPDATE ON rhi_config_bindings
+BEGIN
+ SELECT RAISE(ABORT, 'configuration binding history is immutable');
+END"#
+ };
+}
+
+macro_rules! rhi_config_bindings_no_delete_sql {
+ () => {
+ r#"CREATE TRIGGER rhi_config_bindings_no_delete
+BEFORE DELETE ON rhi_config_bindings
+BEGIN
+ SELECT RAISE(ABORT, 'configuration binding history is retained');
+END"#
+ };
+}
+
+pub(crate) const CREATE_RHI_CONFIG_BINDINGS_TABLE_SQL: &str = rhi_config_bindings_table_sql!();
+const CREATE_RHI_CONFIG_BINDINGS_GUARD_INSERT_SQL: &str = rhi_config_bindings_guard_insert_sql!();
+const CREATE_RHI_CONFIG_BINDINGS_NO_UPDATE_SQL: &str = rhi_config_bindings_no_update_sql!();
+const CREATE_RHI_CONFIG_BINDINGS_NO_DELETE_SQL: &str = rhi_config_bindings_no_delete_sql!();
+const CREATE_RHI_CONFIG_BINDINGS_MIGRATION_SQL: &str = concat!(
+ rhi_config_bindings_table_sql!(),
+ ";\n",
+ rhi_config_bindings_guard_insert_sql!(),
+ ";\n",
+ rhi_config_bindings_no_update_sql!(),
+ ";\n",
+ rhi_config_bindings_no_delete_sql!(),
+ ";",
+);
+
+const RHI_CONFIG_BINDINGS_TABLE_SHA256: [u8; 32] = [
+ 0x4d, 0x6e, 0x8f, 0xff, 0xda, 0x43, 0xe6, 0xf5, 0x3e, 0x23, 0x77, 0xd2, 0x77, 0xa4, 0x52, 0x9e,
+ 0x63, 0x3e, 0xaf, 0xb6, 0xea, 0xa2, 0xad, 0xd7, 0x56, 0xde, 0x0d, 0xc9, 0x24, 0xc5, 0x77, 0xeb,
+];
+const RHI_CONFIG_BINDINGS_GUARD_INSERT_SHA256: [u8; 32] = [
+ 0xe9, 0xc1, 0x7d, 0x5c, 0x2b, 0xbe, 0x59, 0x20, 0x06, 0xe3, 0x7c, 0x5d, 0x93, 0xdc, 0x33, 0x51,
+ 0x42, 0x63, 0xb2, 0xd6, 0x1b, 0x67, 0x57, 0x81, 0x54, 0x63, 0x85, 0x6b, 0x3f, 0x9d, 0x9d, 0x25,
+];
+const RHI_CONFIG_BINDINGS_NO_UPDATE_SHA256: [u8; 32] = [
+ 0xca, 0xb4, 0xbf, 0x42, 0x05, 0x86, 0x03, 0x78, 0x27, 0x1a, 0xad, 0x5b, 0x57, 0x1f, 0x0e, 0x53,
+ 0x61, 0xe6, 0xb6, 0x62, 0xc1, 0xa9, 0xc1, 0x38, 0x07, 0x5f, 0xab, 0x07, 0xcd, 0xc8, 0x92, 0xe0,
+];
+const RHI_CONFIG_BINDINGS_NO_DELETE_SHA256: [u8; 32] = [
+ 0x5d, 0x26, 0x82, 0xe9, 0xf2, 0xdc, 0x84, 0x97, 0x61, 0xd1, 0xd7, 0x10, 0xdc, 0xda, 0x75, 0xea,
+ 0x40, 0x6d, 0x10, 0x95, 0xae, 0x1b, 0xc0, 0xad, 0xdf, 0x70, 0x64, 0xec, 0x8b, 0xed, 0x0b, 0x90,
];
/// Stable classes for invalid embedded RHI catalog definitions.
@@ -100,12 +224,19 @@ impl fmt::Debug for RhiStateCatalogError {
impl Error for RhiStateCatalogError {}
-/// Constructs the exact schema-v1 migration catalog.
+/// Constructs the exact ordered RHI migration catalog.
pub fn rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError> {
- let catalog = MigrationCatalog::new([])
+ let configuration = MigrationDescriptor::sql(
+ 2,
+ "create_configuration_binding_history",
+ CREATE_RHI_CONFIG_BINDINGS_MIGRATION_SQL,
+ MigrationChecksum::from_bytes(RHI_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256),
+ )
+ .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?;
+ let catalog = MigrationCatalog::new([configuration])
.map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?;
if catalog.current_version() != RHI_STATE_SCHEMA_VERSION
- || !catalog.descriptors().is_empty()
+ || catalog.descriptors().len() != 1
|| catalog.digest().as_bytes() != &RHI_MIGRATION_CATALOG_SHA256
{
return Err(RhiStateCatalogError::new(
@@ -118,13 +249,19 @@ pub fn rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError>
/// 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,
+ let version_one = SchemaVersionCatalog::new(
+ RHI_STATE_BASE_SCHEMA_VERSION,
[],
SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_1_SHA256),
)
.map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?;
- let catalog = SchemaCatalog::new(&migrations, [version])
+ let version_two = SchemaVersionCatalog::new(
+ RHI_STATE_SCHEMA_VERSION,
+ rhi_config_binding_objects()?,
+ SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_2_SHA256),
+ )
+ .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?;
+ let catalog = SchemaCatalog::new(&migrations, [version_one, version_two])
.map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?;
validate_rhi_state_catalogs(&migrations, &catalog)?;
Ok(catalog)
@@ -137,13 +274,16 @@ pub fn validate_rhi_state_catalogs(
) -> Result<(), RhiStateCatalogError> {
let versions = schema.versions();
let valid = migrations.current_version() == RHI_STATE_SCHEMA_VERSION
- && migrations.descriptors().is_empty()
+ && migrations.descriptors().len() == 1
&& 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.len() == 2
+ && versions[0].version() == RHI_STATE_BASE_SCHEMA_VERSION
&& versions[0].object_count() == RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT
&& versions[0].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_1_SHA256
+ && versions[1].version() == RHI_STATE_SCHEMA_VERSION
+ && versions[1].object_count() == RHI_STATE_SCHEMA_VERSION_2_OBJECT_COUNT
+ && versions[1].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_2_SHA256
&& schema.digest().as_bytes() == &RHI_STATE_SCHEMA_CATALOG_SHA256;
if valid {
Ok(())
@@ -153,3 +293,40 @@ pub fn validate_rhi_state_catalogs(
))
}
}
+
+fn rhi_config_binding_objects() -> Result<[SchemaObject; 4], RhiStateCatalogError> {
+ Ok([
+ SchemaObject::new(
+ SchemaObjectKind::Table,
+ "rhi_config_bindings",
+ "rhi_config_bindings",
+ CREATE_RHI_CONFIG_BINDINGS_TABLE_SQL,
+ SchemaDigest::from_bytes(RHI_CONFIG_BINDINGS_TABLE_SHA256),
+ )
+ .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?,
+ SchemaObject::new(
+ SchemaObjectKind::Trigger,
+ "rhi_config_bindings_guard_insert",
+ "rhi_config_bindings",
+ CREATE_RHI_CONFIG_BINDINGS_GUARD_INSERT_SQL,
+ SchemaDigest::from_bytes(RHI_CONFIG_BINDINGS_GUARD_INSERT_SHA256),
+ )
+ .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?,
+ SchemaObject::new(
+ SchemaObjectKind::Trigger,
+ "rhi_config_bindings_no_update",
+ "rhi_config_bindings",
+ CREATE_RHI_CONFIG_BINDINGS_NO_UPDATE_SQL,
+ SchemaDigest::from_bytes(RHI_CONFIG_BINDINGS_NO_UPDATE_SHA256),
+ )
+ .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?,
+ SchemaObject::new(
+ SchemaObjectKind::Trigger,
+ "rhi_config_bindings_no_delete",
+ "rhi_config_bindings",
+ CREATE_RHI_CONFIG_BINDINGS_NO_DELETE_SQL,
+ SchemaDigest::from_bytes(RHI_CONFIG_BINDINGS_NO_DELETE_SHA256),
+ )
+ .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?,
+ ])
+}
diff --git a/src/state_config.rs b/src/state_config.rs
@@ -0,0 +1,521 @@
+//! Durable append-only RHI configuration-binding lifecycle.
+
+use core::fmt;
+use std::error::Error;
+
+use radroots_service_sqlite::{
+ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceSqliteTransaction,
+ ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind,
+};
+use sqlx::Row;
+
+use crate::{RhiConfigDocumentV1, RhiStateHost, RhiStateMetadata};
+
+/// Maximum immutable configuration generations retained by one RHI instance.
+pub const RHI_CONFIG_BINDING_MAX_GENERATIONS: u16 = 1024;
+
+const READ_HISTORY_SQL: &str = r#"SELECT generation,
+ normalized_config_sha256, evidence_policy_sha256,
+ length(CAST(service_public_key AS BLOB)) AS service_public_key_bytes,
+ substr(service_public_key, 1, 65) AS service_public_key,
+ config_contract_version, state_contract_version, admin_contract_version,
+ status_contract_version, provider_contract_version, applied_at_unix_s,
+ length(CAST(service_version AS BLOB)) AS service_version_bytes,
+ substr(service_version, 1, 129) AS service_version,
+ length(CAST(service_commit AS BLOB)) AS service_commit_bytes,
+ substr(service_commit, 1, 41) AS service_commit,
+ length(CAST(lib_revision AS BLOB)) AS lib_revision_bytes,
+ substr(lib_revision, 1, 41) AS lib_revision,
+ length(CAST(rust_version AS BLOB)) AS rust_version_bytes,
+ substr(rust_version, 1, 129) AS rust_version,
+ length(CAST(target AS BLOB)) AS target_bytes,
+ substr(target, 1, 129) AS target,
+ length(CAST(feature_profile AS BLOB)) AS feature_profile_bytes,
+ substr(feature_profile, 1, 129) AS feature_profile
+FROM rhi_config_bindings
+ORDER BY generation
+LIMIT 1025"#;
+
+const INSERT_BINDING_SQL: &str = r#"INSERT INTO rhi_config_bindings (
+ generation, normalized_config_sha256, evidence_policy_sha256,
+ service_public_key, config_contract_version, state_contract_version,
+ admin_contract_version, status_contract_version, provider_contract_version,
+ applied_at_unix_s, service_version, service_commit, lib_revision,
+ rust_version, target, feature_profile
+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#;
+
+/// Stable source-free offline configuration-application failure classes.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiConfigApplyErrorKind {
+ InvalidInput,
+ Binding,
+ ResourceExhausted,
+ Transaction,
+ CommitOutcomeUnknown,
+ Close,
+}
+
+impl RhiConfigApplyErrorKind {
+ /// Returns the stable machine-readable failure code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::InvalidInput => "config_apply_input_invalid",
+ Self::Binding => "config_apply_binding_invalid",
+ Self::ResourceExhausted => "resource_exhausted",
+ Self::Transaction => "config_apply_transaction_failed",
+ Self::CommitOutcomeUnknown => "config_apply_commit_outcome_unknown",
+ Self::Close => "config_apply_close_failed",
+ }
+ }
+}
+
+/// One redacted source-free RHI configuration-application failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct RhiConfigApplyError {
+ kind: RhiConfigApplyErrorKind,
+}
+
+impl RhiConfigApplyError {
+ pub(crate) const fn new(kind: RhiConfigApplyErrorKind) -> Self {
+ Self { kind }
+ }
+
+ /// Returns the stable failure class.
+ #[must_use]
+ pub const fn kind(self) -> RhiConfigApplyErrorKind {
+ 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 RhiConfigApplyError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ RhiConfigApplyErrorKind::InvalidInput => "RHI configuration apply evidence is invalid",
+ RhiConfigApplyErrorKind::Binding => "RHI configuration history binding is invalid",
+ RhiConfigApplyErrorKind::ResourceExhausted => {
+ "RHI configuration history capacity is exhausted"
+ }
+ RhiConfigApplyErrorKind::Transaction => "RHI configuration apply transaction failed",
+ RhiConfigApplyErrorKind::CommitOutcomeUnknown => {
+ "RHI configuration apply commit outcome is unknown"
+ }
+ RhiConfigApplyErrorKind::Close => "RHI configuration apply state could not close",
+ })
+ }
+}
+
+impl fmt::Debug for RhiConfigApplyError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiConfigApplyError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl Error for RhiConfigApplyError {}
+
+/// Committed immutable configuration-generation evidence.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct RhiConfigApplyOutcome {
+ generation: u16,
+ changed: bool,
+}
+
+impl RhiConfigApplyOutcome {
+ /// Returns the committed consecutive configuration generation.
+ #[must_use]
+ pub const fn generation(self) -> u16 {
+ self.generation
+ }
+
+ /// Returns whether this call appended a new durable generation.
+ #[must_use]
+ pub const fn changed(self) -> bool {
+ self.changed
+ }
+}
+
+impl fmt::Debug for RhiConfigApplyOutcome {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiConfigApplyOutcome")
+ .field("generation", &self.generation)
+ .field("changed", &self.changed)
+ .finish()
+ }
+}
+
+#[derive(Clone, PartialEq, Eq)]
+struct ConfigBinding {
+ normalized_config_sha256: [u8; 32],
+ evidence_policy_sha256: [u8; 32],
+ service_public_key: Box<str>,
+ config_contract_version: u32,
+ state_contract_version: u32,
+ admin_contract_version: u32,
+ status_contract_version: u32,
+ provider_contract_version: u32,
+}
+
+impl ConfigBinding {
+ fn is_governed(&self) -> bool {
+ self.config_contract_version == crate::RHI_CONFIG_SCHEMA_VERSION
+ && self.state_contract_version == crate::RHI_STATE_SCHEMA_VERSION
+ && self.admin_contract_version == crate::RHI_ADMIN_CONTRACT_VERSION
+ && self.status_contract_version == crate::RHI_STATUS_CONTRACT_VERSION
+ && self.provider_contract_version == crate::RHI_PROVIDER_CONTRACT_VERSION
+ }
+}
+
+impl From<&RhiStateMetadata> for ConfigBinding {
+ fn from(metadata: &RhiStateMetadata) -> Self {
+ let versions = metadata.policy_versions();
+ Self {
+ normalized_config_sha256: *metadata.configuration_digest().as_bytes(),
+ evidence_policy_sha256: *metadata.evidence_policy_digest().as_bytes(),
+ service_public_key: metadata.expected_identity().as_hex().into(),
+ config_contract_version: versions.configuration(),
+ state_contract_version: versions.state(),
+ admin_contract_version: versions.admin(),
+ status_contract_version: versions.status(),
+ provider_contract_version: versions.provider(),
+ }
+ }
+}
+
+#[derive(Clone)]
+struct HistoryEntry {
+ generation: u16,
+ binding: ConfigBinding,
+ applied_at_unix_s: u64,
+ build: MigrationBuildIdentity,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum ConfigOperationError {
+ InvalidInput,
+ Binding,
+ ResourceExhausted,
+ Storage,
+}
+
+pub(crate) async fn bind_or_verify(
+ host: &RhiStateHost,
+ expected: &RhiStateMetadata,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+) -> Result<(), RhiConfigApplyError> {
+ let expected = ConfigBinding::from(expected);
+ let build = build.clone();
+ host.sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let mut history = read_history(transaction).await?;
+ // Schema migration and the service-owned first binding cannot
+ // share one transaction. Treat an empty append-only table as
+ // an interrupted one-time initialization so a retry can
+ // finish binding the admitted configuration. Once any row
+ // exists, the table triggers make this path unreachable
+ // without external database tampering.
+ if history.is_empty() {
+ insert_binding(transaction, 1, &expected, applied_at.get(), &build).await?;
+ history = read_history(transaction).await?;
+ }
+ validate_history(&history)?;
+ match history.last() {
+ Some(actual) if actual.binding == expected => Ok(()),
+ Some(_) | None => Err(ConfigOperationError::Binding),
+ }
+ })
+ })
+ .await
+ .map_err(map_transaction_error)
+}
+
+pub(crate) async fn verify_binding(
+ host: &RhiStateHost,
+ expected: &RhiStateMetadata,
+) -> Result<(), RhiConfigApplyError> {
+ let expected = ConfigBinding::from(expected);
+ host.sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let history = read_history(transaction).await?;
+ validate_history(&history)?;
+ match history.last() {
+ Some(actual) if actual.binding == expected => Ok(()),
+ Some(_) | None => Err(ConfigOperationError::Binding),
+ }
+ })
+ })
+ .await
+ .map_err(map_transaction_error)
+}
+
+pub(crate) async fn append_configuration(
+ host: &RhiStateHost,
+ current: &RhiStateMetadata,
+ candidate: &RhiStateMetadata,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+) -> Result<RhiConfigApplyOutcome, RhiConfigApplyError> {
+ let current = ConfigBinding::from(current);
+ let candidate = ConfigBinding::from(candidate);
+ let build = build.clone();
+ host.sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let history = read_history(transaction).await?;
+ validate_history(&history)?;
+ let latest = history.last().ok_or(ConfigOperationError::Binding)?;
+ if latest.binding != current {
+ return Err(ConfigOperationError::Binding);
+ }
+ if latest.binding == candidate {
+ return Ok(RhiConfigApplyOutcome {
+ generation: latest.generation,
+ changed: false,
+ });
+ }
+ if latest.generation >= RHI_CONFIG_BINDING_MAX_GENERATIONS {
+ return Err(ConfigOperationError::ResourceExhausted);
+ }
+ if applied_at.get() < latest.applied_at_unix_s {
+ return Err(ConfigOperationError::InvalidInput);
+ }
+ let generation = latest.generation + 1;
+ insert_binding(
+ transaction,
+ generation,
+ &candidate,
+ applied_at.get(),
+ &build,
+ )
+ .await?;
+ let updated = read_history(transaction).await?;
+ validate_history(&updated)?;
+ let actual = updated.last().ok_or(ConfigOperationError::Binding)?;
+ if actual.generation != generation || actual.binding != candidate {
+ return Err(ConfigOperationError::Binding);
+ }
+ Ok(RhiConfigApplyOutcome {
+ generation,
+ changed: true,
+ })
+ })
+ })
+ .await
+ .map_err(map_transaction_error)
+}
+
+async fn read_history(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+) -> Result<Vec<HistoryEntry>, ConfigOperationError> {
+ let rows = sqlx::query(READ_HISTORY_SQL)
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| ConfigOperationError::Storage)?;
+ if rows.len() > usize::from(RHI_CONFIG_BINDING_MAX_GENERATIONS) {
+ return Err(ConfigOperationError::ResourceExhausted);
+ }
+ rows.into_iter().map(decode_entry).collect()
+}
+
+fn decode_entry(row: sqlx::sqlite::SqliteRow) -> Result<HistoryEntry, ConfigOperationError> {
+ let generation = bounded_u16(&row, "generation", 1, RHI_CONFIG_BINDING_MAX_GENERATIONS)?;
+ let normalized_config_sha256 = exact_digest(&row, "normalized_config_sha256")?;
+ let evidence_policy_sha256 = exact_digest(&row, "evidence_policy_sha256")?;
+ let service_public_key = bounded_text(&row, "service_public_key", 64, 64)?;
+ if !service_public_key
+ .bytes()
+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
+ || nostr::PublicKey::from_hex(&service_public_key).is_err()
+ {
+ return Err(ConfigOperationError::Binding);
+ }
+ let config_contract_version = positive_u32(&row, "config_contract_version")?;
+ let state_contract_version = positive_u32(&row, "state_contract_version")?;
+ let admin_contract_version = positive_u32(&row, "admin_contract_version")?;
+ let status_contract_version = positive_u32(&row, "status_contract_version")?;
+ let provider_contract_version = positive_u32(&row, "provider_contract_version")?;
+ let applied_at_unix_s = nonnegative_u64(&row, "applied_at_unix_s")?;
+ let service_version = bounded_text(&row, "service_version", 1, 128)?;
+ let service_commit = bounded_text(&row, "service_commit", 40, 40)?;
+ let lib_revision = bounded_text(&row, "lib_revision", 40, 40)?;
+ let rust_version = bounded_text(&row, "rust_version", 1, 128)?;
+ let target = bounded_text(&row, "target", 1, 128)?;
+ let feature_profile = bounded_text(&row, "feature_profile", 1, 128)?;
+ let build = MigrationBuildIdentity::new(
+ &*service_version,
+ &*service_commit,
+ &*lib_revision,
+ &*rust_version,
+ &*target,
+ &*feature_profile,
+ config_contract_version,
+ state_contract_version,
+ admin_contract_version,
+ status_contract_version,
+ provider_contract_version,
+ )
+ .map_err(|_| ConfigOperationError::Binding)?;
+ Ok(HistoryEntry {
+ generation,
+ binding: ConfigBinding {
+ normalized_config_sha256,
+ evidence_policy_sha256,
+ service_public_key,
+ config_contract_version,
+ state_contract_version,
+ admin_contract_version,
+ status_contract_version,
+ provider_contract_version,
+ },
+ applied_at_unix_s,
+ build,
+ })
+}
+
+fn validate_history(history: &[HistoryEntry]) -> Result<(), ConfigOperationError> {
+ let mut previous_time = 0;
+ for (index, entry) in history.iter().enumerate() {
+ if usize::from(entry.generation) != index + 1
+ || entry.applied_at_unix_s < previous_time
+ || !entry.binding.is_governed()
+ || entry.build.config_contract_version() != entry.binding.config_contract_version
+ || entry.build.state_contract_version() != entry.binding.state_contract_version
+ || entry.build.admin_contract_version() != entry.binding.admin_contract_version
+ || entry.build.status_contract_version() != entry.binding.status_contract_version
+ || entry.build.provider_contract_version() != entry.binding.provider_contract_version
+ {
+ return Err(ConfigOperationError::Binding);
+ }
+ previous_time = entry.applied_at_unix_s;
+ }
+ Ok(())
+}
+
+async fn insert_binding(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ generation: u16,
+ binding: &ConfigBinding,
+ applied_at_unix_s: u64,
+ build: &MigrationBuildIdentity,
+) -> Result<(), ConfigOperationError> {
+ sqlx::query(INSERT_BINDING_SQL)
+ .bind(i64::from(generation))
+ .bind(binding.normalized_config_sha256.as_slice())
+ .bind(binding.evidence_policy_sha256.as_slice())
+ .bind(binding.service_public_key.as_ref())
+ .bind(i64::from(binding.config_contract_version))
+ .bind(i64::from(binding.state_contract_version))
+ .bind(i64::from(binding.admin_contract_version))
+ .bind(i64::from(binding.status_contract_version))
+ .bind(i64::from(binding.provider_contract_version))
+ .bind(i64::try_from(applied_at_unix_s).map_err(|_| ConfigOperationError::InvalidInput)?)
+ .bind(build.service_version())
+ .bind(build.service_commit())
+ .bind(build.lib_revision())
+ .bind(build.rust_version())
+ .bind(build.target())
+ .bind(build.feature_profile())
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| ConfigOperationError::Storage)?;
+ Ok(())
+}
+
+fn exact_digest(
+ row: &sqlx::sqlite::SqliteRow,
+ field: &str,
+) -> Result<[u8; 32], ConfigOperationError> {
+ let value = row
+ .try_get::<Vec<u8>, _>(field)
+ .map_err(|_| ConfigOperationError::Binding)?;
+ value.try_into().map_err(|_| ConfigOperationError::Binding)
+}
+
+fn bounded_text(
+ row: &sqlx::sqlite::SqliteRow,
+ field: &str,
+ minimum: usize,
+ maximum: usize,
+) -> Result<Box<str>, ConfigOperationError> {
+ let length_field = format!("{field}_bytes");
+ let length = row
+ .try_get::<i64, _>(length_field.as_str())
+ .ok()
+ .and_then(|value| usize::try_from(value).ok())
+ .filter(|value| (minimum..=maximum).contains(value))
+ .ok_or(ConfigOperationError::Binding)?;
+ let value = row
+ .try_get::<String, _>(field)
+ .map_err(|_| ConfigOperationError::Binding)?;
+ if value.len() != length {
+ return Err(ConfigOperationError::Binding);
+ }
+ Ok(value.into_boxed_str())
+}
+
+fn bounded_u16(
+ row: &sqlx::sqlite::SqliteRow,
+ field: &str,
+ minimum: u16,
+ maximum: u16,
+) -> Result<u16, ConfigOperationError> {
+ row.try_get::<i64, _>(field)
+ .ok()
+ .and_then(|value| u16::try_from(value).ok())
+ .filter(|value| (minimum..=maximum).contains(value))
+ .ok_or(ConfigOperationError::Binding)
+}
+
+fn positive_u32(row: &sqlx::sqlite::SqliteRow, field: &str) -> Result<u32, ConfigOperationError> {
+ row.try_get::<i64, _>(field)
+ .ok()
+ .and_then(|value| u32::try_from(value).ok())
+ .filter(|value| *value != 0)
+ .ok_or(ConfigOperationError::Binding)
+}
+
+fn nonnegative_u64(
+ row: &sqlx::sqlite::SqliteRow,
+ field: &str,
+) -> Result<u64, ConfigOperationError> {
+ row.try_get::<i64, _>(field)
+ .ok()
+ .and_then(|value| u64::try_from(value).ok())
+ .ok_or(ConfigOperationError::Binding)
+}
+
+fn map_transaction_error(
+ error: ServiceSqliteTransactionError<ConfigOperationError>,
+) -> RhiConfigApplyError {
+ if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown {
+ return RhiConfigApplyError::new(RhiConfigApplyErrorKind::CommitOutcomeUnknown);
+ }
+ let kind = match error.operation_error().copied() {
+ Some(ConfigOperationError::InvalidInput) => RhiConfigApplyErrorKind::InvalidInput,
+ Some(ConfigOperationError::Binding) => RhiConfigApplyErrorKind::Binding,
+ Some(ConfigOperationError::ResourceExhausted) => RhiConfigApplyErrorKind::ResourceExhausted,
+ Some(ConfigOperationError::Storage) | None => RhiConfigApplyErrorKind::Transaction,
+ };
+ RhiConfigApplyError::new(kind)
+}
+
+pub(crate) fn metadata_for_configuration(
+ runtime: &crate::RhiRuntimeContext,
+ configuration: &RhiConfigDocumentV1,
+ actual: &radroots_service_sqlite::ServiceDatabaseMetadata,
+) -> Result<RhiStateMetadata, RhiConfigApplyError> {
+ RhiStateMetadata::from_existing_database(runtime, configuration, actual)
+ .map_err(|_| RhiConfigApplyError::new(RhiConfigApplyErrorKind::InvalidInput))
+}
diff --git a/src/state_host.rs b/src/state_host.rs
@@ -7,16 +7,19 @@ use std::{
};
use radroots_service_sqlite::{
- BackupCreatedAtUnixMs, IntegrityCheckedAtUnixMs, MigrationAppliedAtUnixSeconds,
- MigrationBuildIdentity, OpenMode, ServiceBackupManifest, ServiceSqliteConnectionOptions,
- ServiceSqliteHost, ServiceSqliteIntegrityReport, ServiceSqlitePaths, initialize_database,
+ BackupCreatedAtUnixMs, ExistingServiceDatabaseIntent, IntegrityCheckedAtUnixMs,
+ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceBackupManifest,
+ ServiceSqliteApplicationId, ServiceSqliteConnectionOptions, ServiceSqliteHost,
+ ServiceSqliteIntegrityReport, ServiceSqlitePaths, initialize_database,
};
use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions};
use crate::{
- RHI_STATE_SCHEMA_VERSION, RhiRuntimeContext, RhiStateMaintenanceError,
- RhiStateMaintenanceErrorKind, RhiStateMetadata, RhiStateRepositories, rhi_migration_catalog,
- rhi_schema_catalog, validate_rhi_state_catalogs,
+ RHI_STATE_APPLICATION_ID, RHI_STATE_BASE_SCHEMA_VERSION, RHI_STATE_SCHEMA_VERSION,
+ RhiConfigApplyError, RhiConfigApplyErrorKind, RhiConfigApplyOutcome, RhiConfigDocumentV1,
+ RhiRuntimeContext, RhiStateMaintenanceError, RhiStateMaintenanceErrorKind, RhiStateMetadata,
+ RhiStateRepositories, rhi_migration_catalog, rhi_schema_catalog, state_config,
+ validate_rhi_state_catalogs,
};
/// Stable lifecycle mode of one opened RHI state host.
@@ -132,6 +135,9 @@ pub struct RhiStateHost {
}
impl RhiStateHost {
+ pub(crate) const fn sqlite_host(&self) -> &ServiceSqliteHost {
+ &self.host
+ }
/// Returns the lifecycle mode selected when this host was opened.
#[must_use]
pub const fn mode(&self) -> RhiStateHostMode {
@@ -219,7 +225,7 @@ pub async fn initialize_rhi_state(
let authority = initialize_database(
&paths,
OpenMode::Initialize,
- metadata.database(),
+ metadata.initial_database_metadata(),
&schema,
initialize_empty_catalog,
)
@@ -242,7 +248,19 @@ pub async fn initialize_rhi_state(
if !exact_migration_outcome(outcome) {
return Err(close_error(&host, RhiStateHostErrorKind::Catalog).await);
}
- host.close()
+ let state = RhiStateHost {
+ host,
+ mode: RhiStateHostMode::ReadWriteExisting,
+ metadata: metadata.clone(),
+ };
+ if state_config::bind_or_verify(&state, metadata, applied_at, build)
+ .await
+ .is_err()
+ {
+ return Err(close_error(&state.host, RhiStateHostErrorKind::Initialize).await);
+ }
+ state
+ .close()
.await
.map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Initialize))
}
@@ -250,8 +268,8 @@ pub async fn initialize_rhi_state(
/// 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.
+/// explicit injected evidence for every governed migration or exact-current
+/// reopen.
pub async fn open_rhi_state_read_write(
runtime: &RhiRuntimeContext,
metadata: &RhiStateMetadata,
@@ -278,11 +296,125 @@ pub async fn open_rhi_state_read_write(
if !exact_migration_outcome(outcome) {
return Err(close_error(&host, RhiStateHostErrorKind::Catalog).await);
}
- Ok(RhiStateHost {
+ let state = RhiStateHost {
host,
mode: RhiStateHostMode::ReadWriteExisting,
metadata: metadata.clone(),
- })
+ };
+ if state_config::bind_or_verify(&state, metadata, applied_at, build)
+ .await
+ .is_err()
+ {
+ return Err(close_error(&state.host, RhiStateHostErrorKind::InvalidEvidence).await);
+ }
+ Ok(state)
+}
+
+/// Opens existing RHI state from configuration intent and discovers source identity.
+///
+/// Missing state is never created. Source generation and database creation time
+/// are read under the same retained writer authority returned in the host.
+pub async fn open_rhi_state_read_write_from_config(
+ runtime: &RhiRuntimeContext,
+ configuration: &RhiConfigDocumentV1,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+) -> Result<RhiStateHost, RhiStateHostError> {
+ let paths = state_paths(runtime)?;
+ let intent = existing_intent(&paths)?;
+ let (migrations, schema) = catalogs()?;
+ let (opened, outcome) = ServiceSqliteHost::open_read_write_existing_with_intent(
+ &paths,
+ &intent,
+ &migrations,
+ &schema,
+ ServiceSqliteConnectionOptions::reviewed(),
+ applied_at,
+ build,
+ &[],
+ )
+ .await
+ .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::ReadWriteOpen))?;
+ if !exact_migration_outcome(outcome) {
+ let (host, _) = opened.into_parts();
+ return Err(close_error(&host, RhiStateHostErrorKind::Catalog).await);
+ }
+ let (host, actual) = opened.into_parts();
+ let metadata = match RhiStateMetadata::from_existing_database(runtime, configuration, &actual) {
+ Ok(metadata) => metadata,
+ Err(_) => {
+ return Err(close_error(&host, RhiStateHostErrorKind::InvalidEvidence).await);
+ }
+ };
+ if require_migration_build(&metadata, build).is_err() {
+ return Err(close_error(&host, RhiStateHostErrorKind::InvalidEvidence).await);
+ }
+ let state = RhiStateHost {
+ host,
+ mode: RhiStateHostMode::ReadWriteExisting,
+ metadata,
+ };
+ if state_config::bind_or_verify(&state, state.metadata(), applied_at, build)
+ .await
+ .is_err()
+ {
+ return Err(close_error(&state.host, RhiStateHostErrorKind::InvalidEvidence).await);
+ }
+ Ok(state)
+}
+
+/// Applies one admitted RHI configuration while the service is offline.
+///
+/// The function obtains exclusive writer authority, verifies the current
+/// durable binding, appends only bounded digest/public-identity/build evidence,
+/// and explicitly closes state before returning. It never stores raw TOML,
+/// paths, URLs, credential references, or protected identity material.
+pub async fn apply_rhi_configuration(
+ runtime: &RhiRuntimeContext,
+ current: &RhiConfigDocumentV1,
+ candidate: &RhiConfigDocumentV1,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+) -> Result<RhiConfigApplyOutcome, RhiConfigApplyError> {
+ let state = open_rhi_state_read_write_from_config(runtime, current, applied_at, build)
+ .await
+ .map_err(|_| RhiConfigApplyError::new(RhiConfigApplyErrorKind::Binding))?;
+ let candidate_metadata = match state_config::metadata_for_configuration(
+ runtime,
+ candidate,
+ state.metadata().initial_database_metadata(),
+ ) {
+ Ok(metadata) => metadata,
+ Err(error) => return Err(close_apply_error(&state, error.kind()).await),
+ };
+ if require_migration_build(&candidate_metadata, build).is_err() {
+ return Err(close_apply_error(&state, RhiConfigApplyErrorKind::InvalidInput).await);
+ }
+ let outcome = state_config::append_configuration(
+ &state,
+ state.metadata(),
+ &candidate_metadata,
+ applied_at,
+ build,
+ )
+ .await;
+ let closed = state.close().await;
+ if closed.is_err() {
+ Err(RhiConfigApplyError::new(RhiConfigApplyErrorKind::Close))
+ } else {
+ outcome
+ }
+}
+
+async fn close_apply_error(
+ state: &RhiStateHost,
+ fallback: RhiConfigApplyErrorKind,
+) -> RhiConfigApplyError {
+ if state.close().await.is_err() {
+ RhiConfigApplyError::new(RhiConfigApplyErrorKind::Close)
+ } else {
+ RhiConfigApplyError::new(fallback)
+ }
}
/// Opens an already initialized RHI catalog for immutable inspection.
@@ -303,11 +435,18 @@ pub async fn open_rhi_state_inspection(
)
.await
.map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::InspectionOpen))?;
- Ok(RhiStateHost {
+ let state = RhiStateHost {
host,
mode: RhiStateHostMode::ReadOnlyInspection,
metadata: metadata.clone(),
- })
+ };
+ if state_config::verify_binding(&state, metadata)
+ .await
+ .is_err()
+ {
+ return Err(close_error(&state.host, RhiStateHostErrorKind::InvalidEvidence).await);
+ }
+ Ok(state)
}
pub(crate) fn state_paths(
@@ -325,7 +464,12 @@ pub(crate) fn require_metadata(
let matches = metadata.matches_runtime(runtime)
&& database.service() == runtime.context().service()
&& database.instance() == runtime.context().instance()
- && database.state_schema_version().get() == RHI_STATE_SCHEMA_VERSION;
+ && database.state_schema_version().get() == RHI_STATE_BASE_SCHEMA_VERSION
+ && metadata
+ .database_identity()
+ .supported_state_schema_version()
+ .get()
+ == RHI_STATE_SCHEMA_VERSION;
matches
.then_some(())
.ok_or_else(|| RhiStateHostError::new(RhiStateHostErrorKind::InvalidEvidence))
@@ -347,9 +491,23 @@ fn require_migration_build(
}
fn exact_migration_outcome(outcome: radroots_service_sqlite::MigrationApplicationOutcome) -> bool {
- outcome.initial_version() == RHI_STATE_SCHEMA_VERSION
+ (RHI_STATE_BASE_SCHEMA_VERSION..=RHI_STATE_SCHEMA_VERSION).contains(&outcome.initial_version())
&& outcome.final_version() == RHI_STATE_SCHEMA_VERSION
- && outcome.applied_count() == 0
+ && outcome.applied_count() == RHI_STATE_SCHEMA_VERSION - outcome.initial_version()
+}
+
+fn existing_intent(
+ paths: &ServiceSqlitePaths,
+) -> Result<ExistingServiceDatabaseIntent, RhiStateHostError> {
+ let version = core::num::NonZeroU32::new(RHI_STATE_SCHEMA_VERSION)
+ .ok_or_else(|| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?;
+ let application = ServiceSqliteApplicationId::new(RHI_STATE_APPLICATION_ID)
+ .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?;
+ Ok(ExistingServiceDatabaseIntent::new(
+ paths,
+ version,
+ application,
+ ))
}
async fn close_error(
diff --git a/src/state_metadata.rs b/src/state_metadata.rs
@@ -13,8 +13,8 @@ use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::{
- RHI_CONFIG_SCHEMA_VERSION, RHI_STATE_SCHEMA_VERSION, RhiBootstrapProfileV1,
- RhiConfigDocumentV1, RhiConfigProfile, RhiRuntimeContext,
+ RHI_CONFIG_SCHEMA_VERSION, RHI_STATE_BASE_SCHEMA_VERSION, RHI_STATE_SCHEMA_VERSION,
+ RhiBootstrapProfileV1, RhiConfigDocumentV1, RhiConfigProfile, RhiRuntimeContext,
};
const NORMALIZED_CONFIG_DIGEST_DOMAIN: &[u8] = b"radroots.rhi.normalized_config.v1\0";
@@ -158,6 +158,7 @@ impl RhiStatePolicyVersions {
pub struct RhiStateMetadata {
paths: ServiceSqlitePaths,
database: ServiceDatabaseMetadata,
+ database_identity: ServiceDatabaseIdentity,
configuration: RhiNormalizedConfigDigest,
evidence_policy: RhiEvidencePolicyDigest,
identity: RhiExpectedPublicIdentity,
@@ -178,7 +179,7 @@ impl RhiStateMetadata {
.map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Paths))?;
let application_id = ServiceSqliteApplicationId::new(RHI_STATE_APPLICATION_ID)
.map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Invariant))?;
- let state_schema_version = core::num::NonZeroU32::new(RHI_STATE_SCHEMA_VERSION)
+ let state_schema_version = core::num::NonZeroU32::new(RHI_STATE_BASE_SCHEMA_VERSION)
.ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Invariant))?;
let database = ServiceDatabaseMetadata::new(
&paths,
@@ -188,6 +189,15 @@ impl RhiStateMetadata {
application_id,
)
.map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Database))?;
+ let supported_state_schema_version =
+ core::num::NonZeroU32::new(RHI_STATE_SCHEMA_VERSION)
+ .ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Invariant))?;
+ let database_identity = ServiceDatabaseIdentity::new(
+ &paths,
+ source_generation,
+ supported_state_schema_version,
+ application_id,
+ );
let normalized = configuration.normalized();
let configuration_digest = normalized_config_digest(configuration.profile(), normalized)?;
let evidence_policy = evidence_policy_digest(normalized)?;
@@ -209,6 +219,7 @@ impl RhiStateMetadata {
Ok(Self {
paths,
database,
+ database_identity,
configuration: configuration_digest,
evidence_policy,
identity,
@@ -216,16 +227,47 @@ impl RhiStateMetadata {
})
}
- /// Returns the shared immutable database metadata.
+ pub(crate) fn from_existing_database(
+ runtime: &RhiRuntimeContext,
+ configuration: &RhiConfigDocumentV1,
+ actual: &ServiceDatabaseMetadata,
+ ) -> Result<Self, RhiStateMetadataError> {
+ let expected_application = ServiceSqliteApplicationId::new(RHI_STATE_APPLICATION_ID)
+ .map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Invariant))?;
+ if actual.service() != runtime.context().service()
+ || actual.instance() != runtime.context().instance()
+ || actual.application_id() != expected_application
+ || actual.state_schema_version().get() < RHI_STATE_BASE_SCHEMA_VERSION
+ || actual.state_schema_version().get() > RHI_STATE_SCHEMA_VERSION
+ {
+ return Err(RhiStateMetadataError::new(
+ RhiStateMetadataErrorKind::Database,
+ ));
+ }
+ Self::new(
+ runtime,
+ configuration,
+ actual.source_generation(),
+ actual.created_at_unix_ms(),
+ )
+ }
+
+ /// Returns the immutable shared schema-v1 initialization metadata.
#[must_use]
- pub const fn database(&self) -> &ServiceDatabaseMetadata {
+ pub const fn initial_database_metadata(&self) -> &ServiceDatabaseMetadata {
&self.database
}
+ /// Returns the immutable shared schema-v1 initialization metadata.
+ #[must_use]
+ pub const fn database(&self) -> &ServiceDatabaseMetadata {
+ self.initial_database_metadata()
+ }
+
/// Returns the reopen identity derived from the immutable database metadata.
#[must_use]
pub fn database_identity(&self) -> ServiceDatabaseIdentity {
- self.database.identity()
+ self.database_identity.clone()
}
/// Returns the normalized configuration digest.
diff --git a/tests/build_policy.rs b/tests/build_policy.rs
@@ -27,7 +27,7 @@ fn source_lock_metadata_is_exact_and_nix_is_absent() {
));
for field in [
"config_contract_version = 1",
- "state_contract_version = 1",
+ "state_contract_version = 2",
"admin_contract_version = 1",
"status_contract_version = 1",
"provider_contract_version = 1",
@@ -93,7 +93,7 @@ fn source_lock_binds_the_current_cargo_lock() {
assert!(!SOURCE_LOCK.contains("flake_lock_sha256"));
assert!(!SOURCE_LOCK.contains("lib_revision ="));
assert!(SOURCE_LOCK.ends_with(
- "[contract_versions]\nconfig = 1\nstate = 1\nadmin = 1\nstatus = 1\nprovider = 1\n"
+ "[contract_versions]\nconfig = 1\nstate = 2\nadmin = 1\nstatus = 1\nprovider = 1\n"
));
}
diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs
@@ -10,6 +10,9 @@ const FEATURES: &str = include_str!("../src/features/mod.rs");
const RUNTIME_ADAPTERS: &str = include_str!("../src/runtime_adapters.rs");
const RUNTIME_ADAPTER_CONTRACT: &str =
include_str!("../contracts/services_hardening/runtime_adapters.v1.json");
+const RUNTIME_FOUNDATION: &str = include_str!("../src/runtime_foundation.rs");
+const RUNTIME_FOUNDATION_CONTRACT: &str =
+ include_str!("../contracts/services_hardening/runtime_foundation.v1.json");
const PUBLIC_API: &str = include_str!("../contracts/api_baselines/rhi.txt");
const SOURCES: &[&str] = &[
include_str!("../src/adapters/nostr/event.rs"),
@@ -20,7 +23,9 @@ const SOURCES: &[&str] = &[
include_str!("../src/identity_envelope.rs"),
include_str!("../src/runtime_context.rs"),
include_str!("../src/runtime_adapters.rs"),
+ include_str!("../src/runtime_foundation.rs"),
include_str!("../src/state_catalog.rs"),
+ include_str!("../src/state_config.rs"),
include_str!("../src/state_host.rs"),
include_str!("../src/state_maintenance.rs"),
include_str!("../src/state_metadata.rs"),
@@ -81,7 +86,9 @@ fn state_catalog_module_is_private_and_root_api_is_curated() {
"identity_envelope",
"runtime_context",
"runtime_adapters",
+ "runtime_foundation",
"state_catalog",
+ "state_config",
"state_host",
"state_maintenance",
"state_metadata",
@@ -113,6 +120,11 @@ fn state_catalog_module_is_private_and_root_api_is_curated() {
"RhiTransportAdapters",
"RhiCredentialAccess",
"RhiIdentityAccess",
+ "RhiRuntimeFoundation",
+ "RhiRuntimeReadiness",
+ "open_rhi_runtime_foundation",
+ "apply_rhi_configuration",
+ "RhiConfigApplyOutcome",
"WallClock",
"MonotonicClock",
"EntropySource",
@@ -170,7 +182,42 @@ fn public_errors_are_crate_owned_redacted_and_source_free() {
.lines()
.filter(|line| line.starts_with("pub struct rhi::") && line.ends_with("Error"))
.count();
- assert_eq!(public_error_count, 11);
+ assert_eq!(public_error_count, 13);
+}
+
+#[test]
+fn runtime_foundation_is_existing_only_passive_and_process_neutral() {
+ let contract: serde_json::Value =
+ serde_json::from_str(RUNTIME_FOUNDATION_CONTRACT).expect("runtime foundation contract");
+ assert_eq!(contract["schema"], "radroots.rhi.runtime-foundation");
+ assert_eq!(contract["schema_version"], 1);
+ assert_eq!(contract["state_open"]["initialize_if_missing"], false);
+ assert_eq!(
+ contract["state_open"]["durable_configuration_binding_required"],
+ true
+ );
+ assert_eq!(contract["transport"]["invoked_during_foundation"], false);
+ assert_eq!(contract["task_ownership"]["task_handles_exposed"], false);
+ for required in [
+ "open_rhi_state_read_write_from_config",
+ "RhiIdentityEnvelopeBinding::from_configuration",
+ ".identity_credential()",
+ "startup_readiness(&configuration)",
+ ] {
+ assert!(RUNTIME_FOUNDATION.contains(required));
+ }
+ for forbidden in [
+ "tokio::runtime",
+ "tokio::signal",
+ "signal_hook",
+ "tracing_subscriber",
+ "std::process::exit",
+ ".fetch(",
+ ".subscribe(",
+ ".deliver(",
+ ] {
+ assert!(!RUNTIME_FOUNDATION.contains(forbidden));
+ }
}
#[test]
@@ -259,6 +306,12 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() {
"Constructing the adapter set performs no clock read",
"no signal handler, Tokio runtime, logger, or process-exit policy",
"[`runtime_adapters.v1.json`](contracts/services_hardening/runtime_adapters.v1.json)",
+ "## Existing-state runtime foundation",
+ "opens only an already initialized database",
+ "No evidence source, live subscription, or publication sink is",
+ "[`runtime_foundation.v1.json`](contracts/services_hardening/runtime_foundation.v1.json)",
+ "at most 1,024 consecutive generations",
+ "never stores raw TOML, paths, relay URLs, credential",
] {
assert!(README.contains(required), "README is missing {required}");
}
diff --git a/tests/services_hardening_config_lifecycle.rs b/tests/services_hardening_config_lifecycle.rs
@@ -0,0 +1,337 @@
+#![forbid(unsafe_code)]
+#![cfg(any(target_os = "linux", target_os = "macos"))]
+
+use std::{
+ fs,
+ os::unix::fs::PermissionsExt,
+ path::{Path, PathBuf},
+};
+
+use radroots_service_sqlite::{
+ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode,
+ ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, initialize_database,
+};
+use radroots_storage::event::SourceGeneration;
+use rhi::{
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigApplyErrorKind,
+ RhiConfigProfile, RhiStateMetadata, apply_rhi_configuration, initialize_rhi_state,
+ open_rhi_state_read_write_from_config, parse_rhi_cli_v1_from, parse_rhi_config_v1,
+ resolve_rhi_runtime_context, rhi_migration_catalog, rhi_schema_catalog,
+};
+use sqlx::{ConnectOptions, Connection, Row, SqliteConnection, sqlite::SqliteConnectOptions};
+
+const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
+const CONFIG_SOURCE: &str = include_str!("../src/state_config.rs");
+const HOST_SOURCE: &str = include_str!("../src/state_host.rs");
+
+fn runtime(root: &Path) -> rhi::RhiRuntimeContext {
+ let invocation = parse_rhi_cli_v1_from([
+ "rhi",
+ "--profile",
+ "repo-local",
+ "--instance",
+ "primary",
+ "--repo-local-root",
+ root.to_str().expect("UTF-8 root"),
+ "run",
+ ])
+ .expect("invocation");
+ resolve_rhi_runtime_context(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ &invocation,
+ )
+ .expect("runtime")
+}
+
+fn configuration(source: &str) -> rhi::RhiConfigDocumentV1 {
+ parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal).expect("configuration")
+}
+
+fn evidence(at: u64) -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) {
+ let applied_at = MigrationAppliedAtUnixSeconds::new(at).expect("time");
+ let build = MigrationBuildIdentity::new(
+ env!("CARGO_PKG_VERSION"),
+ "1111111111111111111111111111111111111111",
+ "7d7b454b4c9ed86569671993bd03ca868b676665",
+ "rustc-test",
+ "test-target",
+ "service-host",
+ 1,
+ rhi::RHI_STATE_SCHEMA_VERSION,
+ 1,
+ 1,
+ 1,
+ )
+ .expect("build");
+ (applied_at, build)
+}
+
+async fn offline_connection(runtime: &rhi::RhiRuntimeContext) -> SqliteConnection {
+ let options = SqliteConnectOptions::new()
+ .filename(runtime.artifacts().state_database())
+ .create_if_missing(false)
+ .disable_statement_logging();
+ SqliteConnection::connect_with(&options)
+ .await
+ .expect("offline connection")
+}
+
+async fn initialize_empty_catalog(path: PathBuf) -> Result<(), std::io::Error> {
+ let options = SqliteConnectOptions::new()
+ .filename(path)
+ .create_if_missing(false)
+ .disable_statement_logging();
+ let connection = SqliteConnection::connect_with(&options)
+ .await
+ .map_err(|_| std::io::Error::other("database open failed"))?;
+ connection
+ .close()
+ .await
+ .map_err(|_| std::io::Error::other("database close failed"))
+}
+
+#[tokio::test]
+async fn existing_intent_and_offline_apply_bind_exact_append_only_evidence() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ fs::create_dir_all(runtime.context().paths().state()).expect("state directory");
+ fs::set_permissions(
+ runtime.context().paths().state(),
+ fs::Permissions::from_mode(0o700),
+ )
+ .expect("state mode");
+ let current = configuration(EXAMPLE);
+ let metadata = RhiStateMetadata::new(
+ &runtime,
+ ¤t,
+ SourceGeneration::new([0x5a; 32]).expect("generation"),
+ 1_725_000_000_000,
+ )
+ .expect("metadata");
+ let (applied_at, build) = evidence(1_725_000_000);
+ initialize_rhi_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("initialize");
+
+ let state = open_rhi_state_read_write_from_config(&runtime, ¤t, applied_at, &build)
+ .await
+ .expect("intent open");
+ assert_eq!(
+ state.metadata().database().source_generation(),
+ metadata.database().source_generation()
+ );
+ state.close().await.expect("close");
+
+ let changed_source = EXAMPLE.replacen("level = \"info\"", "level = \"debug\"", 1);
+ let changed = configuration(&changed_source);
+ let (second_at, second_build) = evidence(1_725_000_001);
+ let outcome = apply_rhi_configuration(&runtime, ¤t, &changed, second_at, &second_build)
+ .await
+ .expect("offline apply");
+ assert_eq!(outcome.generation(), 2);
+ assert!(outcome.changed());
+ let replay = apply_rhi_configuration(&runtime, &changed, &changed, second_at, &second_build)
+ .await
+ .expect("idempotent replay");
+ assert_eq!(replay.generation(), 2);
+ assert!(!replay.changed());
+
+ let old = open_rhi_state_read_write_from_config(&runtime, ¤t, second_at, &second_build)
+ .await
+ .expect_err("stale config must fail closed");
+ assert_eq!(old.kind(), rhi::RhiStateHostErrorKind::InvalidEvidence);
+ let accepted =
+ open_rhi_state_read_write_from_config(&runtime, &changed, second_at, &second_build)
+ .await
+ .expect("new config accepted");
+ accepted.close().await.expect("close");
+
+ let mut connection = offline_connection(&runtime).await;
+ let rows = sqlx::query(
+ "SELECT generation, length(normalized_config_sha256) AS config_bytes,
+ length(evidence_policy_sha256) AS policy_bytes, service_public_key,
+ state_contract_version, applied_at_unix_s
+ FROM rhi_config_bindings ORDER BY generation",
+ )
+ .fetch_all(&mut connection)
+ .await
+ .expect("history");
+ assert_eq!(rows.len(), 2);
+ assert_eq!(rows[0].try_get::<i64, _>("generation").unwrap(), 1);
+ assert_eq!(rows[1].try_get::<i64, _>("generation").unwrap(), 2);
+ for row in &rows {
+ assert_eq!(row.try_get::<i64, _>("config_bytes").unwrap(), 32);
+ assert_eq!(row.try_get::<i64, _>("policy_bytes").unwrap(), 32);
+ assert_eq!(row.try_get::<i64, _>("state_contract_version").unwrap(), 2);
+ assert_eq!(
+ row.try_get::<String, _>("service_public_key")
+ .unwrap()
+ .len(),
+ 64
+ );
+ }
+ assert!(
+ rows[1].try_get::<i64, _>("applied_at_unix_s").unwrap()
+ >= rows[0].try_get::<i64, _>("applied_at_unix_s").unwrap()
+ );
+ assert!(
+ sqlx::query("UPDATE rhi_config_bindings SET generation = generation")
+ .execute(&mut connection)
+ .await
+ .is_err()
+ );
+ assert!(
+ sqlx::query("DELETE FROM rhi_config_bindings")
+ .execute(&mut connection)
+ .await
+ .is_err()
+ );
+ connection.close().await.expect("connection close");
+
+ let bytes = fs::read(runtime.artifacts().state_database()).expect("database bytes");
+ for forbidden in [
+ directory.path().to_string_lossy().as_bytes(),
+ b"wss://relay-primary.example".as_slice(),
+ b"service_wrapping_key".as_slice(),
+ b"level = \"debug\"".as_slice(),
+ ] {
+ assert!(
+ !bytes
+ .windows(forbidden.len())
+ .any(|window| window == forbidden)
+ );
+ }
+}
+
+#[tokio::test]
+async fn apply_requires_current_binding_and_monotonic_time_without_lock_leak() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ fs::create_dir_all(runtime.context().paths().state()).expect("state directory");
+ fs::set_permissions(
+ runtime.context().paths().state(),
+ fs::Permissions::from_mode(0o700),
+ )
+ .expect("state mode");
+ let current = configuration(EXAMPLE);
+ let metadata = RhiStateMetadata::new(
+ &runtime,
+ ¤t,
+ SourceGeneration::new([0x5a; 32]).expect("generation"),
+ 1_725_000_000_000,
+ )
+ .expect("metadata");
+ let (applied_at, build) = evidence(100);
+ initialize_rhi_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("initialize");
+ let changed = configuration(&EXAMPLE.replacen("level = \"info\"", "level = \"debug\"", 1));
+ let (earlier, earlier_build) = evidence(99);
+ let error = apply_rhi_configuration(&runtime, ¤t, &changed, earlier, &earlier_build)
+ .await
+ .expect_err("time rollback");
+ assert_eq!(error.kind(), RhiConfigApplyErrorKind::InvalidInput);
+ let reopened = open_rhi_state_read_write_from_config(&runtime, ¤t, applied_at, &build)
+ .await
+ .expect("authority released after failed apply");
+ reopened.close().await.expect("close");
+}
+
+#[tokio::test]
+async fn interrupted_first_binding_resumes_after_schema_migration() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ fs::create_dir_all(runtime.context().paths().state()).expect("state directory");
+ fs::set_permissions(
+ runtime.context().paths().state(),
+ fs::Permissions::from_mode(0o700),
+ )
+ .expect("state mode");
+ let current = configuration(EXAMPLE);
+ let metadata = RhiStateMetadata::new(
+ &runtime,
+ ¤t,
+ SourceGeneration::new([0x5a; 32]).expect("generation"),
+ 1_725_000_000_000,
+ )
+ .expect("metadata");
+ let (applied_at, build) = evidence(1_725_000_000);
+ let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()).expect("paths");
+ let migrations = rhi_migration_catalog().expect("migrations");
+ let schema = rhi_schema_catalog().expect("schema");
+ let authority = initialize_database(
+ &paths,
+ OpenMode::Initialize,
+ metadata.initial_database_metadata(),
+ &schema,
+ initialize_empty_catalog,
+ )
+ .await
+ .expect("baseline initialize");
+ let (host, outcome) = ServiceSqliteHost::open_initialized(
+ &paths,
+ &metadata.database_identity(),
+ &migrations,
+ &schema,
+ ServiceSqliteConnectionOptions::reviewed(),
+ authority,
+ applied_at,
+ &build,
+ &[],
+ )
+ .await
+ .expect("schema migration");
+ assert_eq!(
+ outcome.initial_version(),
+ rhi::RHI_STATE_BASE_SCHEMA_VERSION
+ );
+ assert_eq!(outcome.final_version(), rhi::RHI_STATE_SCHEMA_VERSION);
+ host.close().await.expect("close before binding");
+
+ let mut connection = offline_connection(&runtime).await;
+ let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM rhi_config_bindings")
+ .fetch_one(&mut connection)
+ .await
+ .expect("empty binding count");
+ assert_eq!(count, 0);
+ connection.close().await.expect("connection close");
+
+ let resumed = open_rhi_state_read_write_from_config(&runtime, ¤t, applied_at, &build)
+ .await
+ .expect("resume first binding");
+ resumed.close().await.expect("resumed close");
+ let mut connection = offline_connection(&runtime).await;
+ let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM rhi_config_bindings")
+ .fetch_one(&mut connection)
+ .await
+ .expect("seeded binding count");
+ assert_eq!(count, 1);
+ connection.close().await.expect("connection close");
+}
+
+#[test]
+fn configuration_lifecycle_surface_is_sealed_and_redacted() {
+ assert!(HOST_SOURCE.contains("open_read_write_existing_with_intent"));
+ assert!(CONFIG_SOURCE.contains("LIMIT 1025"));
+ assert!(CONFIG_SOURCE.contains("RHI_CONFIG_BINDING_MAX_GENERATIONS"));
+ for forbidden in [
+ "pub host:",
+ "pub transaction:",
+ "raw_sql",
+ "rusqlite",
+ "std::env",
+ ] {
+ assert!(!CONFIG_SOURCE.contains(forbidden), "found {forbidden}");
+ }
+ for kind in [
+ RhiConfigApplyErrorKind::InvalidInput,
+ RhiConfigApplyErrorKind::Binding,
+ RhiConfigApplyErrorKind::ResourceExhausted,
+ RhiConfigApplyErrorKind::Transaction,
+ RhiConfigApplyErrorKind::CommitOutcomeUnknown,
+ RhiConfigApplyErrorKind::Close,
+ ] {
+ let rendered = format!("{kind:?}");
+ assert!(!rendered.is_empty());
+ }
+}
diff --git a/tests/services_hardening_runtime_foundation.rs b/tests/services_hardening_runtime_foundation.rs
@@ -0,0 +1,316 @@
+#![forbid(unsafe_code)]
+#![cfg(any(target_os = "linux", target_os = "macos"))]
+
+use std::{
+ fs,
+ os::unix::fs::PermissionsExt,
+ path::Path,
+ sync::{
+ Arc,
+ atomic::{AtomicUsize, Ordering},
+ },
+};
+
+use nostr::{Keys, SecretKey};
+use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity};
+use radroots_storage::event::SourceGeneration;
+use radroots_transport::{
+ BoxFuture, BoxSubscription, DeliveryReceipt, DeliveryRequest, Error as TransportError,
+ EventSink, EventSource, EventSubscriber, FetchPage, FetchRequest, SinkFailure, SinkStatus,
+ SourceStatus, SubscriptionRequest,
+};
+use rhi::{
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigDocumentV1,
+ RhiConfigProfile, RhiEncryptedIdentityProvisioningMaterial, RhiIdentityCredentialAdapters,
+ RhiIdentityEnvelopeBinding, RhiRuntimeAdapters, RhiRuntimeFoundationErrorKind,
+ RhiRuntimePrerequisite, RhiStateMetadata, RhiTimeEntropyAdapters, RhiTransportAdapters,
+ initialize_rhi_state, open_rhi_runtime_foundation, open_rhi_state_read_write,
+ parse_rhi_cli_v1_from, parse_rhi_config_v1, provision_rhi_encrypted_identity,
+ resolve_rhi_runtime_context, resolve_rhi_wrapping_credential,
+};
+use sha2::{Digest, Sha256};
+
+const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
+const FOUNDATION_SOURCE: &str = include_str!("../src/runtime_foundation.rs");
+const CONTRACT_SOURCE: &str =
+ include_str!("../contracts/services_hardening/runtime_foundation.v1.json");
+
+fn digest(label: &str) -> [u8; 32] {
+ Sha256::digest(label.as_bytes()).into()
+}
+
+fn identity_secret() -> [u8; 32] {
+ let mut candidate = digest("radroots.rhi.runtime-foundation.identity.v1");
+ while SecretKey::from_slice(&candidate).is_err() {
+ candidate = Sha256::digest(candidate).into();
+ }
+ candidate
+}
+
+fn runtime(root: &Path) -> rhi::RhiRuntimeContext {
+ let invocation = parse_rhi_cli_v1_from([
+ "rhi",
+ "--profile",
+ "repo-local",
+ "--instance",
+ "primary",
+ "--repo-local-root",
+ root.to_str().expect("UTF-8 root"),
+ "run",
+ ])
+ .expect("invocation");
+ resolve_rhi_runtime_context(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ &invocation,
+ )
+ .expect("runtime")
+}
+
+fn configuration(runtime: &rhi::RhiRuntimeContext, expected_identity: &str) -> RhiConfigDocumentV1 {
+ let source = CONFIG_EXAMPLE
+ .replace(
+ "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt",
+ runtime.identity_path().to_str().expect("identity path"),
+ )
+ .replace(&"2".repeat(64), expected_identity);
+ parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal).expect("configuration")
+}
+
+fn evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) {
+ let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("time");
+ let build = MigrationBuildIdentity::new(
+ env!("CARGO_PKG_VERSION"),
+ "1111111111111111111111111111111111111111",
+ "7d7b454b4c9ed86569671993bd03ca868b676665",
+ "rustc-test",
+ "test-target",
+ "service-host",
+ 1,
+ rhi::RHI_STATE_SCHEMA_VERSION,
+ 1,
+ 1,
+ 1,
+ )
+ .expect("build");
+ (applied_at, build)
+}
+
+fn prepare(
+ runtime: &rhi::RhiRuntimeContext,
+ configuration: &RhiConfigDocumentV1,
+) -> RhiStateMetadata {
+ for directory in [
+ runtime.context().paths().state(),
+ runtime.context().paths().secrets(),
+ ] {
+ fs::create_dir_all(directory).expect("directory");
+ fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("mode");
+ }
+ let metadata = RhiStateMetadata::new(
+ runtime,
+ configuration,
+ SourceGeneration::new([0x6b; 32]).expect("generation"),
+ 1_725_000_000_000,
+ )
+ .expect("metadata");
+ let binding = RhiIdentityEnvelopeBinding::from_configuration(configuration, &metadata)
+ .expect("identity binding");
+ let credential_bytes = digest("radroots.rhi.runtime-foundation.credential.v1");
+ let credential_path = runtime
+ .context()
+ .paths()
+ .secrets()
+ .join("service_wrapping_key");
+ fs::write(&credential_path, credential_bytes).expect("credential");
+ fs::set_permissions(&credential_path, fs::Permissions::from_mode(0o600))
+ .expect("credential mode");
+ let credential = resolve_rhi_wrapping_credential(runtime, &binding).expect("credential open");
+ provision_rhi_encrypted_identity(
+ &binding,
+ &credential,
+ RhiEncryptedIdentityProvisioningMaterial::new(
+ identity_secret(),
+ digest("radroots.rhi.runtime-foundation.data-key.v1"),
+ [7; 24],
+ [9; 24],
+ )
+ .expect("material"),
+ )
+ .expect("identity provision");
+ metadata
+}
+
+#[derive(Clone)]
+struct TransportSpy(Arc<AtomicUsize>);
+
+impl TransportSpy {
+ fn invoked(&self) -> usize {
+ self.0.load(Ordering::SeqCst)
+ }
+
+ fn mark(&self) {
+ self.0.fetch_add(1, Ordering::SeqCst);
+ }
+}
+
+impl EventSource for TransportSpy {
+ fn status(&self) -> BoxFuture<'_, Result<SourceStatus, TransportError>> {
+ self.mark();
+ Box::pin(async { panic!("foundation must not observe source status") })
+ }
+
+ fn fetch(&self, _request: FetchRequest) -> BoxFuture<'_, Result<FetchPage, TransportError>> {
+ self.mark();
+ Box::pin(async { panic!("foundation must not fetch") })
+ }
+}
+
+impl EventSubscriber for TransportSpy {
+ fn subscribe(
+ &self,
+ _request: SubscriptionRequest,
+ ) -> BoxFuture<'_, Result<BoxSubscription, TransportError>> {
+ self.mark();
+ Box::pin(async { panic!("foundation must not subscribe") })
+ }
+}
+
+impl EventSink for TransportSpy {
+ fn status(&self) -> BoxFuture<'_, Result<SinkStatus, TransportError>> {
+ self.mark();
+ Box::pin(async { panic!("foundation must not observe sink status") })
+ }
+
+ fn deliver(
+ &self,
+ _request: DeliveryRequest,
+ ) -> BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> {
+ self.mark();
+ Box::pin(async { panic!("foundation must not publish") })
+ }
+}
+
+fn adapters(spy: &TransportSpy) -> RhiRuntimeAdapters {
+ RhiRuntimeAdapters::new(
+ RhiTimeEntropyAdapters::system(),
+ RhiTransportAdapters::new(
+ Arc::new(spy.clone()),
+ Arc::new(spy.clone()),
+ Arc::new(spy.clone()),
+ ),
+ RhiIdentityCredentialAdapters::canonical(),
+ )
+}
+
+#[tokio::test]
+async fn foundation_proves_state_config_identity_and_contacts_no_transport() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ let secret = identity_secret();
+ let identity = Keys::new(SecretKey::from_slice(&secret).expect("secret"))
+ .public_key()
+ .to_hex();
+ let configuration = configuration(&runtime, &identity);
+ let metadata = prepare(&runtime, &configuration);
+ let (applied_at, build) = evidence();
+ initialize_rhi_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("initialize");
+ let spy = TransportSpy(Arc::new(AtomicUsize::new(0)));
+ let foundation = open_rhi_runtime_foundation(
+ runtime.clone(),
+ configuration,
+ adapters(&spy),
+ applied_at,
+ &build,
+ )
+ .await
+ .expect("foundation");
+
+ assert_eq!(spy.invoked(), 0);
+ assert_eq!(
+ foundation.metadata().database().source_generation(),
+ metadata.database().source_generation()
+ );
+ assert!(!foundation.readiness().is_ready());
+ assert_eq!(
+ foundation.readiness().satisfied(),
+ [
+ RhiRuntimePrerequisite::ExistingState,
+ RhiRuntimePrerequisite::DurableConfiguration,
+ RhiRuntimePrerequisite::VerifiedIdentity,
+ ]
+ );
+ assert!(
+ foundation
+ .readiness()
+ .required()
+ .contains(&RhiRuntimePrerequisite::PresenceDesiredState)
+ );
+ assert!(!foundation.readiness().reasons().is_empty());
+ let rendered = format!("{foundation:?}");
+ assert!(!rendered.contains(directory.path().to_string_lossy().as_ref()));
+ assert!(!rendered.contains(&identity));
+
+ let contended = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect_err("foundation retains writer authority");
+ assert_eq!(contended.kind(), rhi::RhiStateHostErrorKind::ReadWriteOpen);
+ foundation.shutdown().await.expect("shutdown");
+ let reopened = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("reopen");
+ reopened.close().await.expect("close");
+}
+
+#[tokio::test]
+async fn missing_or_mismatched_state_fails_before_identity_or_transport_access() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ let secret = identity_secret();
+ let identity = Keys::new(SecretKey::from_slice(&secret).expect("secret"))
+ .public_key()
+ .to_hex();
+ let configuration = configuration(&runtime, &identity);
+ fs::create_dir_all(runtime.context().paths().state()).expect("state directory");
+ fs::set_permissions(
+ runtime.context().paths().state(),
+ fs::Permissions::from_mode(0o700),
+ )
+ .expect("state mode");
+ let spy = TransportSpy(Arc::new(AtomicUsize::new(0)));
+ let (applied_at, build) = evidence();
+ let error = open_rhi_runtime_foundation(
+ runtime.clone(),
+ configuration,
+ adapters(&spy),
+ applied_at,
+ &build,
+ )
+ .await
+ .expect_err("missing state");
+ assert_eq!(error.kind(), RhiRuntimeFoundationErrorKind::StateOpen);
+ assert_eq!(spy.invoked(), 0);
+ assert!(!runtime.artifacts().state_database().exists());
+}
+
+#[test]
+fn source_and_contract_keep_final_runtime_authority_deferred() {
+ let contract: serde_json::Value = serde_json::from_str(CONTRACT_SOURCE).expect("contract");
+ assert_eq!(contract["transport"]["invoked_during_foundation"], false);
+ assert_eq!(contract["state_open"]["initialize_if_missing"], false);
+ assert!(FOUNDATION_SOURCE.contains("open_rhi_state_read_write_from_config"));
+ for forbidden in [
+ "tokio::runtime",
+ "ctrl_c",
+ "signal_hook",
+ "std::process::exit",
+ ".fetch(",
+ ".subscribe(",
+ ".deliver(",
+ "println!",
+ "tracing::",
+ ] {
+ assert!(!FOUNDATION_SOURCE.contains(forbidden), "found {forbidden}");
+ }
+}
diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs
@@ -7,8 +7,10 @@ use radroots_service_sqlite::{
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,
+ RHI_MIGRATION_CATALOG_SHA256, RHI_STATE_BASE_SCHEMA_VERSION, RHI_STATE_SCHEMA_CATALOG_SHA256,
+ RHI_STATE_SCHEMA_VERSION, RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT,
+ RHI_STATE_SCHEMA_VERSION_1_SHA256, RHI_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256,
+ RHI_STATE_SCHEMA_VERSION_2_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_2_SHA256,
RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog,
validate_rhi_state_catalogs,
};
@@ -18,19 +20,29 @@ 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() {
+fn schema_v1_to_v2_catalogs_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!(RHI_STATE_BASE_SCHEMA_VERSION, 1);
+ assert_eq!(RHI_STATE_SCHEMA_VERSION, 2);
+ assert_eq!(migrations.descriptors().len(), 1);
+ assert_eq!(migrations.current_version(), 2);
+ assert_eq!(migrations.descriptors()[0].target_version(), 2);
+ assert_eq!(
+ migrations.descriptors()[0].name().as_str(),
+ "create_configuration_binding_history"
+ );
+ assert_eq!(
+ migrations.descriptors()[0].checksum().as_bytes(),
+ &RHI_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256
+ );
assert_eq!(
migrations.digest().as_bytes(),
&RHI_MIGRATION_CATALOG_SHA256
);
- assert_eq!(schema.versions().len(), 1);
+ assert_eq!(schema.versions().len(), 2);
let version = schema.versions()[0];
assert_eq!(version.version(), 1);
assert_eq!(
@@ -42,21 +54,40 @@ fn schema_v1_and_empty_migration_catalog_have_exact_literal_identities() {
version.digest().as_bytes(),
&RHI_STATE_SCHEMA_VERSION_1_SHA256
);
+ let version = schema.versions()[1];
+ assert_eq!(version.version(), 2);
+ assert_eq!(
+ version.object_count(),
+ RHI_STATE_SCHEMA_VERSION_2_OBJECT_COUNT
+ );
+ assert_eq!(version.object_count(), 10);
+ assert_eq!(
+ version.digest().as_bytes(),
+ &RHI_STATE_SCHEMA_VERSION_2_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"
+ "b640a9095d5318dbfd0afbfbe6e05281113a9cef4564805c021c368c3c52fc08"
);
assert_eq!(
lower_hex(&RHI_STATE_SCHEMA_VERSION_1_SHA256),
"94dc66fbca601679615c055229dc0db6119f5bd92b04390c67f698a036fa78ae"
);
assert_eq!(
+ lower_hex(&RHI_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256),
+ "a2c1aa53f7feee0385e774de20e072520f4926c559c6421aab590e92ba144c55"
+ );
+ assert_eq!(
+ lower_hex(&RHI_STATE_SCHEMA_VERSION_2_SHA256),
+ "bccbf1e62fe7644c9c377205b25a92298e088b8c26d15ba45133ca5e9b7315a9"
+ );
+ assert_eq!(
lower_hex(&RHI_STATE_SCHEMA_CATALOG_SHA256),
- "2309153f3b49754887c548a7459b3e09099c60f7146b373c8f96706c6768d791"
+ "1b7f7359b62ed7dcd476289e46693d9b3d1369dcaf7d5952f0329a5c86f8b85f"
);
}
@@ -75,7 +106,7 @@ fn independent_validator_rejects_migration_or_schema_drift() {
RhiStateCatalogErrorKind::CatalogMismatch
);
- let empty_migrations = rhi_migration_catalog().expect("empty migrations");
+ let exact_migrations = rhi_migration_catalog().expect("exact migrations");
let object_digest =
SchemaObject::computed_digest(SchemaObjectKind::Table, "unexpected", "unexpected", SQL)
.expect("object digest");
@@ -87,12 +118,19 @@ fn independent_validator_rejects_migration_or_schema_drift() {
object_digest,
)
.expect("schema object");
+ let version_one = SchemaVersionCatalog::new(
+ 1,
+ [],
+ radroots_service_sqlite::SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_1_SHA256),
+ )
+ .expect("version one");
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");
+ SchemaVersionCatalog::computed_digest(2, [object.clone()]).expect("snapshot digest");
+ let version_two = SchemaVersionCatalog::new(2, [object], snapshot_digest).expect("version two");
+ let schema = SchemaCatalog::new(&exact_migrations, [version_one, version_two])
+ .expect("drift schema catalog");
assert_eq!(
- validate_rhi_state_catalogs(&empty_migrations, &schema)
+ validate_rhi_state_catalogs(&exact_migrations, &schema)
.expect_err("schema drift")
.kind(),
RhiStateCatalogErrorKind::CatalogMismatch
@@ -117,10 +155,17 @@ fn catalog_errors_are_stable_source_free_and_redacted() {
object_digest,
)
.expect("object");
+ let version_one = SchemaVersionCatalog::new(
+ 1,
+ [],
+ radroots_service_sqlite::SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_1_SHA256),
+ )
+ .expect("version one");
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");
+ SchemaVersionCatalog::computed_digest(2, [object.clone()]).expect("snapshot digest");
+ let version_two = SchemaVersionCatalog::new(2, [object], snapshot).expect("version two");
+ let schema =
+ SchemaCatalog::new(&migrations, [version_one, version_two]).expect("schema catalog");
let error = validate_rhi_state_catalogs(&migrations, &schema).expect_err("mismatch");
assert_eq!(error.kind(), RhiStateCatalogErrorKind::CatalogMismatch);
@@ -138,20 +183,19 @@ fn catalog_source_is_pure_pinned_and_uses_only_the_shared_authority() {
));
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("MigrationDescriptor::sql("));
+ assert!(CATALOG_SOURCE.contains("CREATE TABLE rhi_config_bindings"));
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),
diff --git a/tests/services_hardening_state_host.rs b/tests/services_hardening_state_host.rs
@@ -65,7 +65,7 @@ fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentit
"test-target",
"service-host",
1,
- 1,
+ rhi::RHI_STATE_SCHEMA_VERSION,
1,
1,
1,
diff --git a/tests/services_hardening_state_metadata.rs b/tests/services_hardening_state_metadata.rs
@@ -5,10 +5,10 @@ use std::{error::Error, path::Path};
use radroots_storage::event::SourceGeneration;
use rhi::{
RHI_ADMIN_CONTRACT_VERSION, RHI_CONFIG_SCHEMA_VERSION, RHI_PROVIDER_CONTRACT_VERSION,
- RHI_STATE_APPLICATION_ID, RHI_STATE_SCHEMA_VERSION, RHI_STATUS_CONTRACT_VERSION,
- RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigProfile,
- RhiStateMetadata, RhiStateMetadataErrorKind, parse_rhi_cli_v1_from, parse_rhi_config_v1,
- resolve_rhi_runtime_context,
+ RHI_STATE_APPLICATION_ID, RHI_STATE_BASE_SCHEMA_VERSION, RHI_STATE_SCHEMA_VERSION,
+ RHI_STATUS_CONTRACT_VERSION, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform,
+ RhiConfigProfile, RhiStateMetadata, RhiStateMetadataErrorKind, parse_rhi_cli_v1_from,
+ parse_rhi_config_v1, resolve_rhi_runtime_context,
};
const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
@@ -63,6 +63,13 @@ fn exact_database_configuration_identity_and_policy_bindings_are_frozen() {
assert_eq!(database.source_generation().as_bytes(), &[0x5a; 32]);
assert_eq!(
database.state_schema_version().get(),
+ RHI_STATE_BASE_SCHEMA_VERSION
+ );
+ assert_eq!(
+ metadata
+ .database_identity()
+ .supported_state_schema_version()
+ .get(),
RHI_STATE_SCHEMA_VERSION
);
assert_eq!(database.created_at_unix_ms(), 1_725_000_000_000);
diff --git a/tests/services_hardening_state_resilience.rs b/tests/services_hardening_state_resilience.rs
@@ -279,8 +279,8 @@ async fn exact_open_rejects_unexpected_migration_history_without_repair() {
service_version, service_commit, lib_revision, rust_version, target,
feature_profile, config_contract_version, state_contract_version,
admin_contract_version, status_contract_version, provider_contract_version
- ) VALUES (2, 'unexpected_schema', ?, 1725000000, '0.1.0', ?, ?,
- 'rustc-test', 'test-target', 'service-host', 1, 1, 1, 1, 1)",
+ ) VALUES (3, 'unexpected_schema', ?, 1725000000, '0.1.0', ?, ?,
+ 'rustc-test', 'test-target', 'service-host', 1, 3, 1, 1, 1)",
)
.bind([0x44_u8; 32].as_slice())
.bind("1111111111111111111111111111111111111111")
diff --git a/tests/services_hardening_wave_100_b.rs b/tests/services_hardening_wave_100_b.rs
@@ -97,7 +97,7 @@ fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentit
"test-target",
"service-host",
1,
- 1,
+ rhi::RHI_STATE_SCHEMA_VERSION,
1,
1,
1,