rhi

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

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:
MAGENTS.md | 15+++++++++++++++
MCargo.toml | 2+-
MREADME | 55++++++++++++++++++++++++++++++++++++++++++-------------
Mcontracts/api_baselines/rhi.txt | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/runtime_foundation.v1.json | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mradroots.service.source-lock.v2.toml | 2+-
Msrc/lib.rs | 22++++++++++++++++++----
Msrc/runtime_adapters.rs | 9+++++++++
Asrc/runtime_foundation.rs | 427+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_catalog.rs | 211++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Asrc/state_config.rs | 521+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_host.rs | 192++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/state_metadata.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++------
Mtests/build_policy.rs | 4++--
Mtests/package_boundary.rs | 55++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atests/services_hardening_config_lifecycle.rs | 337+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/services_hardening_runtime_foundation.rs | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_state_catalog.rs | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mtests/services_hardening_state_host.rs | 2+-
Mtests/services_hardening_state_metadata.rs | 15+++++++++++----
Mtests/services_hardening_state_resilience.rs | 4++--
Mtests/services_hardening_wave_100_b.rs | 2+-
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, + &current, + 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, &current, 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, &current, &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, &current, 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, + &current, + 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, &current, &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, &current, 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, + &current, + 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, &current, 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,