state_host.rs (20536B)
1 //! Sealed lifecycle boundary for the canonical RHI SQLite state catalog. 2 3 use core::fmt; 4 use std::{error::Error, path::Path}; 5 6 use radroots_service_sqlite::{ 7 BackupCreatedAtUnixMs, ExistingServiceDatabaseIntent, IntegrityCheckedAtUnixMs, 8 MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceBackupManifest, 9 ServiceSqliteApplicationId, ServiceSqliteConnectionOptions, ServiceSqliteHost, 10 ServiceSqliteInitializer, ServiceSqliteInitializerFuture, ServiceSqliteIntegrityReport, 11 ServiceSqlitePaths, initialize_database, 12 }; 13 14 use crate::{ 15 RHI_STATE_APPLICATION_ID, RHI_STATE_BASE_SCHEMA_VERSION, RHI_STATE_SCHEMA_VERSION, 16 RhiConfigApplyError, RhiConfigApplyErrorKind, RhiConfigApplyOutcome, RhiConfigDocumentV1, 17 RhiRuntimeContext, RhiStateMaintenanceError, RhiStateMaintenanceErrorKind, RhiStateMetadata, 18 RhiStateRepositories, rhi_migration_catalog, rhi_schema_catalog, state_config, 19 validate_rhi_state_catalogs, 20 }; 21 22 /// Stable lifecycle mode of one opened RHI state host. 23 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 24 pub enum RhiStateHostMode { 25 ReadWriteExisting, 26 ReadOnlyInspection, 27 } 28 29 /// Stable source-free class for an RHI state-host lifecycle failure. 30 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 31 pub enum RhiStateHostErrorKind { 32 InvalidPaths, 33 InvalidEvidence, 34 Catalog, 35 Initialize, 36 ReadWriteOpen, 37 InspectionOpen, 38 Close, 39 } 40 41 impl RhiStateHostErrorKind { 42 /// Returns the stable machine-readable failure code. 43 #[must_use] 44 pub const fn code(self) -> &'static str { 45 match self { 46 Self::InvalidPaths => "state_paths_invalid", 47 Self::InvalidEvidence => "state_evidence_invalid", 48 Self::Catalog => "state_catalog_invalid", 49 Self::Initialize => "state_initialize_failed", 50 Self::ReadWriteOpen => "state_read_write_open_failed", 51 Self::InspectionOpen => "state_inspection_open_failed", 52 Self::Close => "state_close_failed", 53 } 54 } 55 } 56 57 /// Redacted RHI state-host lifecycle failure. 58 #[derive(Clone, Copy, PartialEq, Eq)] 59 pub struct RhiStateHostError { 60 kind: RhiStateHostErrorKind, 61 } 62 63 impl RhiStateHostError { 64 const fn new(kind: RhiStateHostErrorKind) -> Self { 65 Self { kind } 66 } 67 68 /// Returns the stable failure class. 69 #[must_use] 70 pub const fn kind(self) -> RhiStateHostErrorKind { 71 self.kind 72 } 73 74 /// Returns the stable machine-readable failure code. 75 #[must_use] 76 pub const fn code(self) -> &'static str { 77 self.kind.code() 78 } 79 } 80 81 impl fmt::Display for RhiStateHostError { 82 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 83 formatter.write_str(match self.kind { 84 RhiStateHostErrorKind::InvalidPaths => "RHI state paths are invalid", 85 RhiStateHostErrorKind::InvalidEvidence => "RHI state identity evidence is invalid", 86 RhiStateHostErrorKind::Catalog => "RHI state catalogs are invalid", 87 RhiStateHostErrorKind::Initialize => "RHI state initialization failed", 88 RhiStateHostErrorKind::ReadWriteOpen => "RHI writable state could not be opened", 89 RhiStateHostErrorKind::InspectionOpen => "RHI inspection state could not be opened", 90 RhiStateHostErrorKind::Close => "RHI state host could not be closed", 91 }) 92 } 93 } 94 95 impl fmt::Debug for RhiStateHostError { 96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 97 formatter 98 .debug_struct("RhiStateHostError") 99 .field("kind", &self.kind) 100 .finish() 101 } 102 } 103 104 impl Error for RhiStateHostError {} 105 106 /// One opened RHI state catalog whose raw SQLite authority remains sealed. 107 /// 108 /// Callers cannot construct the wrapper or extract the shared host: 109 /// 110 /// ```compile_fail 111 /// use rhi::{RhiStateHost, RhiStateHostMode}; 112 /// 113 /// let _ = RhiStateHost { 114 /// host: todo!(), 115 /// mode: RhiStateHostMode::ReadWriteExisting, 116 /// }; 117 /// ``` 118 /// 119 /// The wrapper intentionally exposes no transaction or connection escape: 120 /// 121 /// ```compile_fail 122 /// use rhi::RhiStateHost; 123 /// 124 /// fn bypass(host: &RhiStateHost) { 125 /// let _ = host.transaction(|_| async { Ok::<_, ()>(()) }); 126 /// } 127 /// ``` 128 pub struct RhiStateHost { 129 host: ServiceSqliteHost, 130 mode: RhiStateHostMode, 131 metadata: RhiStateMetadata, 132 } 133 134 impl RhiStateHost { 135 pub(crate) const fn sqlite_host(&self) -> &ServiceSqliteHost { 136 &self.host 137 } 138 /// Returns the lifecycle mode selected when this host was opened. 139 #[must_use] 140 pub const fn mode(&self) -> RhiStateHostMode { 141 self.mode 142 } 143 144 /// Returns the immutable RHI metadata bound to this host session. 145 #[must_use] 146 pub const fn metadata(&self) -> &RhiStateMetadata { 147 &self.metadata 148 } 149 150 /// Returns the sealed family of typed repository capabilities. 151 #[must_use] 152 pub const fn repositories(&self) -> RhiStateRepositories<'_> { 153 RhiStateRepositories::new(self) 154 } 155 156 /// Captures one governed point-in-time backup from a writable RHI host. 157 /// 158 /// The staging directory must be a new absolute path. The returned 159 /// manifest remains in memory and contains no protected identity material. 160 pub async fn capture_online_backup( 161 &self, 162 staging_directory: &Path, 163 created_at: BackupCreatedAtUnixMs, 164 ) -> Result<ServiceBackupManifest, RhiStateMaintenanceError> { 165 if self.mode != RhiStateHostMode::ReadWriteExisting { 166 return Err(RhiStateMaintenanceError::new( 167 RhiStateMaintenanceErrorKind::InvalidMode, 168 )); 169 } 170 self.host 171 .capture_online_backup(staging_directory, created_at) 172 .await 173 .map_err(RhiStateMaintenanceError::from_sqlite) 174 } 175 176 /// Runs one explicit bounded integrity inspection over this host. 177 pub async fn inspect_integrity( 178 &self, 179 checked_at: IntegrityCheckedAtUnixMs, 180 ) -> Result<ServiceSqliteIntegrityReport, RhiStateMaintenanceError> { 181 self.host 182 .inspect_integrity(checked_at) 183 .await 184 .map_err(RhiStateMaintenanceError::from_sqlite) 185 } 186 187 /// Drains the shared host and explicitly releases retained authority. 188 pub async fn close(&self) -> Result<(), RhiStateHostError> { 189 self.host 190 .close() 191 .await 192 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Close)) 193 } 194 } 195 196 impl fmt::Debug for RhiStateHost { 197 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 198 formatter 199 .debug_struct("RhiStateHost") 200 .field("mode", &self.mode) 201 .field("state", &"[sealed]") 202 .finish() 203 } 204 } 205 206 /// Creates a missing RHI catalog exactly once and releases initialization authority. 207 /// 208 /// This function never opens an existing database as initialization. The caller 209 /// injects the shared metadata and migration evidence. Contract versions are 210 /// cross-bound before database I/O, and all governed migrations are applied by 211 /// the shared host before initialization authority is explicitly released. 212 pub async fn initialize_rhi_state( 213 runtime: &RhiRuntimeContext, 214 metadata: &RhiStateMetadata, 215 applied_at: MigrationAppliedAtUnixSeconds, 216 build: &MigrationBuildIdentity, 217 ) -> Result<(), RhiStateHostError> { 218 let paths = state_paths(runtime)?; 219 require_metadata(runtime, metadata)?; 220 require_migration_build(metadata, build)?; 221 let (migrations, schema) = catalogs()?; 222 provision_state_directory(runtime)?; 223 let authority = initialize_database( 224 &paths, 225 OpenMode::Initialize, 226 metadata.initial_database_metadata(), 227 &schema, 228 initialize_empty_catalog, 229 ) 230 .await 231 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Initialize))?; 232 let identity = metadata.database_identity(); 233 let (host, outcome) = ServiceSqliteHost::open_initialized( 234 &paths, 235 &identity, 236 &migrations, 237 &schema, 238 ServiceSqliteConnectionOptions::reviewed(), 239 authority, 240 applied_at, 241 build, 242 &[], 243 ) 244 .await 245 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Initialize))?; 246 if !exact_migration_outcome(outcome) { 247 return Err(close_error(&host, RhiStateHostErrorKind::Catalog).await); 248 } 249 let state = RhiStateHost { 250 host, 251 mode: RhiStateHostMode::ReadWriteExisting, 252 metadata: metadata.clone(), 253 }; 254 if state_config::bind_or_verify(&state, metadata, applied_at, build) 255 .await 256 .is_err() 257 { 258 return Err(close_error(&state.host, RhiStateHostErrorKind::Initialize).await); 259 } 260 state 261 .close() 262 .await 263 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Initialize)) 264 } 265 266 /// Opens an already initialized RHI catalog with exclusive writer authority. 267 /// 268 /// Missing state is never created. Migration time and build identity remain 269 /// explicit injected evidence for every governed migration or exact-current 270 /// reopen. 271 pub async fn open_rhi_state_read_write( 272 runtime: &RhiRuntimeContext, 273 metadata: &RhiStateMetadata, 274 applied_at: MigrationAppliedAtUnixSeconds, 275 build: &MigrationBuildIdentity, 276 ) -> Result<RhiStateHost, RhiStateHostError> { 277 let paths = state_paths(runtime)?; 278 require_metadata(runtime, metadata)?; 279 require_migration_build(metadata, build)?; 280 let identity = metadata.database_identity(); 281 let (migrations, schema) = catalogs()?; 282 let (host, outcome) = ServiceSqliteHost::open_read_write_existing( 283 &paths, 284 &identity, 285 &migrations, 286 &schema, 287 ServiceSqliteConnectionOptions::reviewed(), 288 applied_at, 289 build, 290 &[], 291 ) 292 .await 293 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::ReadWriteOpen))?; 294 if !exact_migration_outcome(outcome) { 295 return Err(close_error(&host, RhiStateHostErrorKind::Catalog).await); 296 } 297 let state = RhiStateHost { 298 host, 299 mode: RhiStateHostMode::ReadWriteExisting, 300 metadata: metadata.clone(), 301 }; 302 if state_config::bind_or_verify(&state, metadata, applied_at, build) 303 .await 304 .is_err() 305 { 306 return Err(close_error(&state.host, RhiStateHostErrorKind::InvalidEvidence).await); 307 } 308 Ok(state) 309 } 310 311 /// Opens existing RHI state from configuration intent and discovers source identity. 312 /// 313 /// Missing state is never created. Source generation and database creation time 314 /// are read under the same retained writer authority returned in the host. 315 pub async fn open_rhi_state_read_write_from_config( 316 runtime: &RhiRuntimeContext, 317 configuration: &RhiConfigDocumentV1, 318 applied_at: MigrationAppliedAtUnixSeconds, 319 build: &MigrationBuildIdentity, 320 ) -> Result<RhiStateHost, RhiStateHostError> { 321 let paths = state_paths(runtime)?; 322 let intent = existing_intent(&paths)?; 323 let (migrations, schema) = catalogs()?; 324 let (opened, outcome) = ServiceSqliteHost::open_read_write_existing_with_intent( 325 &paths, 326 &intent, 327 &migrations, 328 &schema, 329 ServiceSqliteConnectionOptions::reviewed(), 330 applied_at, 331 build, 332 &[], 333 ) 334 .await 335 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::ReadWriteOpen))?; 336 if !exact_migration_outcome(outcome) { 337 let (host, _) = opened.into_parts(); 338 return Err(close_error(&host, RhiStateHostErrorKind::Catalog).await); 339 } 340 let (host, actual) = opened.into_parts(); 341 let metadata = match RhiStateMetadata::from_existing_database(runtime, configuration, &actual) { 342 Ok(metadata) => metadata, 343 Err(_) => { 344 return Err(close_error(&host, RhiStateHostErrorKind::InvalidEvidence).await); 345 } 346 }; 347 if require_migration_build(&metadata, build).is_err() { 348 return Err(close_error(&host, RhiStateHostErrorKind::InvalidEvidence).await); 349 } 350 let state = RhiStateHost { 351 host, 352 mode: RhiStateHostMode::ReadWriteExisting, 353 metadata, 354 }; 355 if state_config::bind_or_verify(&state, state.metadata(), applied_at, build) 356 .await 357 .is_err() 358 { 359 return Err(close_error(&state.host, RhiStateHostErrorKind::InvalidEvidence).await); 360 } 361 Ok(state) 362 } 363 364 /// Applies one admitted RHI configuration while the service is offline. 365 /// 366 /// The function obtains exclusive writer authority, verifies the current 367 /// durable binding, appends only bounded digest/public-identity/build evidence, 368 /// and explicitly closes state before returning. It never stores raw TOML, 369 /// paths, URLs, credential references, or protected identity material. 370 pub async fn apply_rhi_configuration( 371 runtime: &RhiRuntimeContext, 372 current: &RhiConfigDocumentV1, 373 candidate: &RhiConfigDocumentV1, 374 applied_at: MigrationAppliedAtUnixSeconds, 375 build: &MigrationBuildIdentity, 376 ) -> Result<RhiConfigApplyOutcome, RhiConfigApplyError> { 377 let state = open_rhi_state_read_write_from_config(runtime, current, applied_at, build) 378 .await 379 .map_err(|_| RhiConfigApplyError::new(RhiConfigApplyErrorKind::Binding))?; 380 let candidate_metadata = match state_config::metadata_for_configuration( 381 runtime, 382 candidate, 383 state.metadata().initial_database_metadata(), 384 ) { 385 Ok(metadata) => metadata, 386 Err(error) => return Err(close_apply_error(&state, error.kind()).await), 387 }; 388 if require_migration_build(&candidate_metadata, build).is_err() { 389 return Err(close_apply_error(&state, RhiConfigApplyErrorKind::InvalidInput).await); 390 } 391 let outcome = state_config::append_configuration( 392 &state, 393 state.metadata(), 394 &candidate_metadata, 395 applied_at, 396 build, 397 ) 398 .await; 399 let closed = state.close().await; 400 if closed.is_err() { 401 Err(RhiConfigApplyError::new(RhiConfigApplyErrorKind::Close)) 402 } else { 403 outcome 404 } 405 } 406 407 async fn close_apply_error( 408 state: &RhiStateHost, 409 fallback: RhiConfigApplyErrorKind, 410 ) -> RhiConfigApplyError { 411 if state.close().await.is_err() { 412 RhiConfigApplyError::new(RhiConfigApplyErrorKind::Close) 413 } else { 414 RhiConfigApplyError::new(fallback) 415 } 416 } 417 418 /// Opens an already initialized RHI catalog for immutable inspection. 419 pub async fn open_rhi_state_inspection( 420 runtime: &RhiRuntimeContext, 421 metadata: &RhiStateMetadata, 422 ) -> Result<RhiStateHost, RhiStateHostError> { 423 let paths = state_paths(runtime)?; 424 require_metadata(runtime, metadata)?; 425 let identity = metadata.database_identity(); 426 let (migrations, schema) = catalogs()?; 427 let host = ServiceSqliteHost::open_read_only_inspection( 428 &paths, 429 &identity, 430 &migrations, 431 &schema, 432 ServiceSqliteConnectionOptions::reviewed(), 433 ) 434 .await 435 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::InspectionOpen))?; 436 let state = RhiStateHost { 437 host, 438 mode: RhiStateHostMode::ReadOnlyInspection, 439 metadata: metadata.clone(), 440 }; 441 if state_config::verify_binding(&state, metadata) 442 .await 443 .is_err() 444 { 445 return Err(close_error(&state.host, RhiStateHostErrorKind::InvalidEvidence).await); 446 } 447 Ok(state) 448 } 449 450 /// Opens existing inspection state from a sealed intent and actual metadata. 451 pub async fn open_rhi_state_inspection_from_config( 452 runtime: &RhiRuntimeContext, 453 configuration: &RhiConfigDocumentV1, 454 ) -> Result<RhiStateHost, RhiStateHostError> { 455 let paths = state_paths(runtime)?; 456 let (migrations, schema) = catalogs()?; 457 let intent = existing_intent(&paths)?; 458 let opened = ServiceSqliteHost::open_read_only_inspection_with_intent( 459 &paths, 460 &intent, 461 &migrations, 462 &schema, 463 ServiceSqliteConnectionOptions::reviewed(), 464 ) 465 .await 466 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::InspectionOpen))?; 467 let (host, actual) = opened.into_parts(); 468 let metadata = match RhiStateMetadata::from_existing_database(runtime, configuration, &actual) { 469 Ok(metadata) => metadata, 470 Err(_) => { 471 return Err(close_error(&host, RhiStateHostErrorKind::InvalidEvidence).await); 472 } 473 }; 474 let state = RhiStateHost { 475 host, 476 mode: RhiStateHostMode::ReadOnlyInspection, 477 metadata, 478 }; 479 if state_config::verify_binding(&state, state.metadata()) 480 .await 481 .is_err() 482 { 483 return Err(close_error(&state.host, RhiStateHostErrorKind::InvalidEvidence).await); 484 } 485 Ok(state) 486 } 487 488 pub(crate) fn state_paths( 489 runtime: &RhiRuntimeContext, 490 ) -> Result<ServiceSqlitePaths, RhiStateHostError> { 491 ServiceSqlitePaths::from_runtime_context(runtime.context()) 492 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::InvalidPaths)) 493 } 494 495 fn provision_state_directory(runtime: &RhiRuntimeContext) -> Result<(), RhiStateHostError> { 496 runtime 497 .context() 498 .state_directory_plan() 499 .and_then(|plan| plan.provision()) 500 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Initialize)) 501 } 502 503 pub(crate) fn require_metadata( 504 runtime: &RhiRuntimeContext, 505 metadata: &RhiStateMetadata, 506 ) -> Result<(), RhiStateHostError> { 507 let database = metadata.database(); 508 let matches = metadata.matches_runtime(runtime) 509 && database.service() == runtime.context().service() 510 && database.instance() == runtime.context().instance() 511 && database.state_schema_version().get() == RHI_STATE_BASE_SCHEMA_VERSION 512 && metadata 513 .database_identity() 514 .supported_state_schema_version() 515 .get() 516 == RHI_STATE_SCHEMA_VERSION; 517 matches 518 .then_some(()) 519 .ok_or_else(|| RhiStateHostError::new(RhiStateHostErrorKind::InvalidEvidence)) 520 } 521 522 fn require_migration_build( 523 metadata: &RhiStateMetadata, 524 build: &MigrationBuildIdentity, 525 ) -> Result<(), RhiStateHostError> { 526 let versions = metadata.policy_versions(); 527 let matches = build.config_contract_version() == versions.configuration() 528 && build.state_contract_version() == versions.state() 529 && build.admin_contract_version() == versions.admin() 530 && build.status_contract_version() == versions.status() 531 && build.provider_contract_version() == versions.provider(); 532 matches 533 .then_some(()) 534 .ok_or_else(|| RhiStateHostError::new(RhiStateHostErrorKind::InvalidEvidence)) 535 } 536 537 fn exact_migration_outcome(outcome: radroots_service_sqlite::MigrationApplicationOutcome) -> bool { 538 (RHI_STATE_BASE_SCHEMA_VERSION..=RHI_STATE_SCHEMA_VERSION).contains(&outcome.initial_version()) 539 && outcome.final_version() == RHI_STATE_SCHEMA_VERSION 540 && outcome.applied_count() == RHI_STATE_SCHEMA_VERSION - outcome.initial_version() 541 } 542 543 fn existing_intent( 544 paths: &ServiceSqlitePaths, 545 ) -> Result<ExistingServiceDatabaseIntent, RhiStateHostError> { 546 let version = core::num::NonZeroU32::new(RHI_STATE_SCHEMA_VERSION) 547 .ok_or_else(|| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?; 548 let application = ServiceSqliteApplicationId::new(RHI_STATE_APPLICATION_ID) 549 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?; 550 Ok(ExistingServiceDatabaseIntent::new( 551 paths, 552 version, 553 application, 554 )) 555 } 556 557 async fn close_error( 558 host: &ServiceSqliteHost, 559 fallback: RhiStateHostErrorKind, 560 ) -> RhiStateHostError { 561 if host.close().await.is_err() { 562 RhiStateHostError::new(RhiStateHostErrorKind::Close) 563 } else { 564 RhiStateHostError::new(fallback) 565 } 566 } 567 568 pub(crate) fn catalogs() -> Result< 569 ( 570 radroots_service_sqlite::MigrationCatalog, 571 radroots_service_sqlite::SchemaCatalog, 572 ), 573 RhiStateHostError, 574 > { 575 let migrations = rhi_migration_catalog() 576 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?; 577 let schema = 578 rhi_schema_catalog().map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?; 579 validate_rhi_state_catalogs(&migrations, &schema) 580 .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?; 581 Ok((migrations, schema)) 582 } 583 584 fn initialize_empty_catalog<'a>( 585 _initializer: &'a mut ServiceSqliteInitializer<'_>, 586 ) -> ServiceSqliteInitializerFuture<'a, core::convert::Infallible> { 587 Box::pin(async { Ok(()) }) 588 }