state_config.rs (24070B)
1 //! Durable append-only RHI configuration-binding lifecycle. 2 3 use core::fmt; 4 use std::error::Error; 5 6 use radroots_service_sqlite::{ 7 MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceSqliteTransaction, 8 ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, 9 }; 10 use sqlx::Row; 11 12 use crate::{RhiConfigDocumentV1, RhiStateHost, RhiStateMetadata}; 13 14 /// Maximum immutable configuration generations retained by one RHI instance. 15 pub const RHI_CONFIG_BINDING_MAX_GENERATIONS: u16 = 1024; 16 17 const READ_HISTORY_SQL: &str = r#"SELECT generation, 18 normalized_config_sha256, evidence_policy_sha256, 19 length(CAST(service_public_key AS BLOB)) AS service_public_key_bytes, 20 substr(service_public_key, 1, 65) AS service_public_key, 21 config_contract_version, state_contract_version, admin_contract_version, 22 status_contract_version, provider_contract_version, applied_at_unix_s, 23 length(CAST(service_version AS BLOB)) AS service_version_bytes, 24 substr(service_version, 1, 129) AS service_version, 25 length(CAST(service_commit AS BLOB)) AS service_commit_bytes, 26 substr(service_commit, 1, 41) AS service_commit, 27 length(CAST(lib_revision AS BLOB)) AS lib_revision_bytes, 28 substr(lib_revision, 1, 41) AS lib_revision, 29 length(CAST(rust_version AS BLOB)) AS rust_version_bytes, 30 substr(rust_version, 1, 129) AS rust_version, 31 length(CAST(target AS BLOB)) AS target_bytes, 32 substr(target, 1, 129) AS target, 33 length(CAST(feature_profile AS BLOB)) AS feature_profile_bytes, 34 substr(feature_profile, 1, 129) AS feature_profile 35 FROM rhi_config_bindings 36 ORDER BY generation 37 LIMIT 1025"#; 38 39 const INSERT_BINDING_SQL: &str = r#"INSERT INTO rhi_config_bindings ( 40 generation, normalized_config_sha256, evidence_policy_sha256, 41 service_public_key, config_contract_version, state_contract_version, 42 admin_contract_version, status_contract_version, provider_contract_version, 43 applied_at_unix_s, service_version, service_commit, lib_revision, 44 rust_version, target, feature_profile 45 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#; 46 const READ_DIRTY_POLICY_BOUNDS_SQL: &str = r#"SELECT 47 COUNT(*) FILTER (WHERE generation >= 9223372036854775807) AS exhausted, 48 COALESCE(MAX(updated_at_unix_s), 0) AS latest_updated_at 49 FROM trade_dirty_generations 50 WHERE evidence_policy_sha256 != ?"#; 51 const ADVANCE_DIRTY_POLICY_SQL: &str = r#"UPDATE trade_dirty_generations 52 SET generation = generation + 1, evidence_policy_sha256 = ?, updated_at_unix_s = ? 53 WHERE evidence_policy_sha256 != ?"#; 54 55 /// Stable source-free offline configuration-application failure classes. 56 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 57 pub enum RhiConfigApplyErrorKind { 58 InvalidInput, 59 Binding, 60 ResourceExhausted, 61 Transaction, 62 CommitOutcomeUnknown, 63 Close, 64 } 65 66 impl RhiConfigApplyErrorKind { 67 /// Returns the stable machine-readable failure code. 68 #[must_use] 69 pub const fn code(self) -> &'static str { 70 match self { 71 Self::InvalidInput => "config_apply_input_invalid", 72 Self::Binding => "config_apply_binding_invalid", 73 Self::ResourceExhausted => "resource_exhausted", 74 Self::Transaction => "config_apply_transaction_failed", 75 Self::CommitOutcomeUnknown => "config_apply_commit_outcome_unknown", 76 Self::Close => "config_apply_close_failed", 77 } 78 } 79 } 80 81 /// One redacted source-free RHI configuration-application failure. 82 #[derive(Clone, Copy, PartialEq, Eq)] 83 pub struct RhiConfigApplyError { 84 kind: RhiConfigApplyErrorKind, 85 } 86 87 impl RhiConfigApplyError { 88 pub(crate) const fn new(kind: RhiConfigApplyErrorKind) -> Self { 89 Self { kind } 90 } 91 92 /// Returns the stable failure class. 93 #[must_use] 94 pub const fn kind(self) -> RhiConfigApplyErrorKind { 95 self.kind 96 } 97 98 /// Returns the stable machine-readable failure code. 99 #[must_use] 100 pub const fn code(self) -> &'static str { 101 self.kind.code() 102 } 103 } 104 105 impl fmt::Display for RhiConfigApplyError { 106 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 107 formatter.write_str(match self.kind { 108 RhiConfigApplyErrorKind::InvalidInput => "RHI configuration apply evidence is invalid", 109 RhiConfigApplyErrorKind::Binding => "RHI configuration history binding is invalid", 110 RhiConfigApplyErrorKind::ResourceExhausted => { 111 "RHI configuration history capacity is exhausted" 112 } 113 RhiConfigApplyErrorKind::Transaction => "RHI configuration apply transaction failed", 114 RhiConfigApplyErrorKind::CommitOutcomeUnknown => { 115 "RHI configuration apply commit outcome is unknown" 116 } 117 RhiConfigApplyErrorKind::Close => "RHI configuration apply state could not close", 118 }) 119 } 120 } 121 122 impl fmt::Debug for RhiConfigApplyError { 123 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 124 formatter 125 .debug_struct("RhiConfigApplyError") 126 .field("kind", &self.kind) 127 .finish() 128 } 129 } 130 131 impl Error for RhiConfigApplyError {} 132 133 /// Committed immutable configuration-generation evidence. 134 #[derive(Clone, Copy, PartialEq, Eq)] 135 pub struct RhiConfigApplyOutcome { 136 generation: u16, 137 changed: bool, 138 } 139 140 impl RhiConfigApplyOutcome { 141 /// Returns the committed consecutive configuration generation. 142 #[must_use] 143 pub const fn generation(self) -> u16 { 144 self.generation 145 } 146 147 /// Returns whether this call appended a new durable generation. 148 #[must_use] 149 pub const fn changed(self) -> bool { 150 self.changed 151 } 152 } 153 154 impl fmt::Debug for RhiConfigApplyOutcome { 155 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 156 formatter 157 .debug_struct("RhiConfigApplyOutcome") 158 .field("generation", &self.generation) 159 .field("changed", &self.changed) 160 .finish() 161 } 162 } 163 164 #[derive(Clone, PartialEq, Eq)] 165 struct ConfigBinding { 166 normalized_config_sha256: [u8; 32], 167 evidence_policy_sha256: [u8; 32], 168 service_public_key: Box<str>, 169 config_contract_version: u32, 170 state_contract_version: u32, 171 admin_contract_version: u32, 172 status_contract_version: u32, 173 provider_contract_version: u32, 174 } 175 176 impl ConfigBinding { 177 fn is_governed(&self) -> bool { 178 self.config_contract_version == crate::RHI_CONFIG_SCHEMA_VERSION 179 && (crate::RHI_STATE_BASE_SCHEMA_VERSION..=crate::RHI_STATE_SCHEMA_VERSION) 180 .contains(&self.state_contract_version) 181 && self.admin_contract_version == crate::RHI_ADMIN_CONTRACT_VERSION 182 && self.status_contract_version == crate::RHI_STATUS_CONTRACT_VERSION 183 && self.provider_contract_version == crate::RHI_PROVIDER_CONTRACT_VERSION 184 } 185 186 fn is_same_identity_and_policy_except_state_version(&self, other: &Self) -> bool { 187 self.normalized_config_sha256 == other.normalized_config_sha256 188 && self.evidence_policy_sha256 == other.evidence_policy_sha256 189 && self.service_public_key == other.service_public_key 190 && self.config_contract_version == other.config_contract_version 191 && self.admin_contract_version == other.admin_contract_version 192 && self.status_contract_version == other.status_contract_version 193 && self.provider_contract_version == other.provider_contract_version 194 && self.state_contract_version < other.state_contract_version 195 } 196 } 197 198 impl From<&RhiStateMetadata> for ConfigBinding { 199 fn from(metadata: &RhiStateMetadata) -> Self { 200 let versions = metadata.policy_versions(); 201 Self { 202 normalized_config_sha256: *metadata.configuration_digest().as_bytes(), 203 evidence_policy_sha256: *metadata.evidence_policy_digest().as_bytes(), 204 service_public_key: metadata.expected_identity().as_hex().into(), 205 config_contract_version: versions.configuration(), 206 state_contract_version: versions.state(), 207 admin_contract_version: versions.admin(), 208 status_contract_version: versions.status(), 209 provider_contract_version: versions.provider(), 210 } 211 } 212 } 213 214 #[derive(Clone)] 215 struct HistoryEntry { 216 generation: u16, 217 binding: ConfigBinding, 218 applied_at_unix_s: u64, 219 build: MigrationBuildIdentity, 220 } 221 222 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 223 pub(crate) enum ConfigOperationError { 224 InvalidInput, 225 Binding, 226 ResourceExhausted, 227 Storage, 228 } 229 230 pub(crate) async fn bind_or_verify( 231 host: &RhiStateHost, 232 expected: &RhiStateMetadata, 233 applied_at: MigrationAppliedAtUnixSeconds, 234 build: &MigrationBuildIdentity, 235 ) -> Result<(), RhiConfigApplyError> { 236 let expected = ConfigBinding::from(expected); 237 let build = build.clone(); 238 host.sqlite_host() 239 .transaction(move |transaction| { 240 Box::pin(async move { 241 let mut history = read_history(transaction).await?; 242 // Schema migration and the service-owned first binding cannot 243 // share one transaction. Treat an empty append-only table as 244 // an interrupted one-time initialization so a retry can 245 // finish binding the admitted configuration. Once any row 246 // exists, the table triggers make this path unreachable 247 // without external database tampering. 248 if history.is_empty() { 249 insert_binding(transaction, 1, &expected, applied_at.get(), &build).await?; 250 history = read_history(transaction).await?; 251 } 252 validate_history(&history)?; 253 match history.last() { 254 Some(actual) if actual.binding == expected => Ok(()), 255 Some(actual) 256 if actual 257 .binding 258 .is_same_identity_and_policy_except_state_version(&expected) => 259 { 260 if actual.generation >= RHI_CONFIG_BINDING_MAX_GENERATIONS { 261 return Err(ConfigOperationError::ResourceExhausted); 262 } 263 if applied_at.get() < actual.applied_at_unix_s { 264 return Err(ConfigOperationError::InvalidInput); 265 } 266 insert_binding( 267 transaction, 268 actual.generation + 1, 269 &expected, 270 applied_at.get(), 271 &build, 272 ) 273 .await 274 } 275 Some(_) | None => Err(ConfigOperationError::Binding), 276 } 277 }) 278 }) 279 .await 280 .map_err(map_transaction_error) 281 } 282 283 pub(crate) async fn verify_binding( 284 host: &RhiStateHost, 285 expected: &RhiStateMetadata, 286 ) -> Result<(), RhiConfigApplyError> { 287 let expected = ConfigBinding::from(expected); 288 host.sqlite_host() 289 .transaction(move |transaction| { 290 Box::pin(async move { 291 let history = read_history(transaction).await?; 292 validate_history(&history)?; 293 match history.last() { 294 Some(actual) if actual.binding == expected => Ok(()), 295 Some(_) | None => Err(ConfigOperationError::Binding), 296 } 297 }) 298 }) 299 .await 300 .map_err(map_transaction_error) 301 } 302 303 #[cfg(any(target_os = "linux", target_os = "macos"))] 304 pub(crate) async fn current_generation(host: &RhiStateHost) -> Result<u16, RhiConfigApplyError> { 305 host.sqlite_host() 306 .transaction(move |transaction| { 307 Box::pin(async move { 308 let history = read_history(transaction).await?; 309 validate_history(&history)?; 310 history 311 .last() 312 .map(|entry| entry.generation) 313 .ok_or(ConfigOperationError::Binding) 314 }) 315 }) 316 .await 317 .map_err(map_transaction_error) 318 } 319 320 pub(crate) async fn append_configuration( 321 host: &RhiStateHost, 322 current: &RhiStateMetadata, 323 candidate: &RhiStateMetadata, 324 applied_at: MigrationAppliedAtUnixSeconds, 325 build: &MigrationBuildIdentity, 326 ) -> Result<RhiConfigApplyOutcome, RhiConfigApplyError> { 327 let current = ConfigBinding::from(current); 328 let candidate = ConfigBinding::from(candidate); 329 let build = build.clone(); 330 host.sqlite_host() 331 .transaction(move |transaction| { 332 Box::pin(async move { 333 let history = read_history(transaction).await?; 334 validate_history(&history)?; 335 let latest = history.last().ok_or(ConfigOperationError::Binding)?; 336 if latest.binding != current { 337 return Err(ConfigOperationError::Binding); 338 } 339 if latest.binding == candidate { 340 return Ok(RhiConfigApplyOutcome { 341 generation: latest.generation, 342 changed: false, 343 }); 344 } 345 if latest.generation >= RHI_CONFIG_BINDING_MAX_GENERATIONS { 346 return Err(ConfigOperationError::ResourceExhausted); 347 } 348 if applied_at.get() < latest.applied_at_unix_s { 349 return Err(ConfigOperationError::InvalidInput); 350 } 351 let generation = latest.generation + 1; 352 if latest.binding.evidence_policy_sha256 != candidate.evidence_policy_sha256 { 353 advance_dirty_policy( 354 transaction, 355 candidate.evidence_policy_sha256, 356 applied_at.get(), 357 ) 358 .await?; 359 } 360 insert_binding( 361 transaction, 362 generation, 363 &candidate, 364 applied_at.get(), 365 &build, 366 ) 367 .await?; 368 let updated = read_history(transaction).await?; 369 validate_history(&updated)?; 370 let actual = updated.last().ok_or(ConfigOperationError::Binding)?; 371 if actual.generation != generation || actual.binding != candidate { 372 return Err(ConfigOperationError::Binding); 373 } 374 Ok(RhiConfigApplyOutcome { 375 generation, 376 changed: true, 377 }) 378 }) 379 }) 380 .await 381 .map_err(map_transaction_error) 382 } 383 384 async fn advance_dirty_policy( 385 transaction: &mut ServiceSqliteTransaction<'_>, 386 policy: [u8; 32], 387 updated_at_unix_s: u64, 388 ) -> Result<(), ConfigOperationError> { 389 let row = sqlx::query(READ_DIRTY_POLICY_BOUNDS_SQL) 390 .bind(policy.as_slice()) 391 .fetch_one(&mut *transaction) 392 .await 393 .map_err(|_| ConfigOperationError::Storage)?; 394 let exhausted = row 395 .try_get::<i64, _>("exhausted") 396 .map_err(|_| ConfigOperationError::Storage)?; 397 let latest_updated_at = row 398 .try_get::<i64, _>("latest_updated_at") 399 .ok() 400 .and_then(|value| u64::try_from(value).ok()) 401 .ok_or(ConfigOperationError::Storage)?; 402 if exhausted != 0 { 403 return Err(ConfigOperationError::ResourceExhausted); 404 } 405 if updated_at_unix_s < latest_updated_at { 406 return Err(ConfigOperationError::InvalidInput); 407 } 408 sqlx::query(ADVANCE_DIRTY_POLICY_SQL) 409 .bind(policy.as_slice()) 410 .bind(i64::try_from(updated_at_unix_s).map_err(|_| ConfigOperationError::InvalidInput)?) 411 .bind(policy.as_slice()) 412 .execute(&mut *transaction) 413 .await 414 .map_err(|_| ConfigOperationError::Storage)?; 415 Ok(()) 416 } 417 418 async fn read_history( 419 transaction: &mut ServiceSqliteTransaction<'_>, 420 ) -> Result<Vec<HistoryEntry>, ConfigOperationError> { 421 let rows = sqlx::query(READ_HISTORY_SQL) 422 .fetch_all(&mut *transaction) 423 .await 424 .map_err(|_| ConfigOperationError::Storage)?; 425 if rows.len() > usize::from(RHI_CONFIG_BINDING_MAX_GENERATIONS) { 426 return Err(ConfigOperationError::ResourceExhausted); 427 } 428 rows.into_iter().map(decode_entry).collect() 429 } 430 431 fn decode_entry(row: sqlx::sqlite::SqliteRow) -> Result<HistoryEntry, ConfigOperationError> { 432 let generation = bounded_u16(&row, "generation", 1, RHI_CONFIG_BINDING_MAX_GENERATIONS)?; 433 let normalized_config_sha256 = exact_digest(&row, "normalized_config_sha256")?; 434 let evidence_policy_sha256 = exact_digest(&row, "evidence_policy_sha256")?; 435 let service_public_key = bounded_text(&row, "service_public_key", 64, 64)?; 436 if !service_public_key 437 .bytes() 438 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) 439 || nostr::PublicKey::from_hex(&service_public_key).is_err() 440 { 441 return Err(ConfigOperationError::Binding); 442 } 443 let config_contract_version = positive_u32(&row, "config_contract_version")?; 444 let state_contract_version = positive_u32(&row, "state_contract_version")?; 445 let admin_contract_version = positive_u32(&row, "admin_contract_version")?; 446 let status_contract_version = positive_u32(&row, "status_contract_version")?; 447 let provider_contract_version = positive_u32(&row, "provider_contract_version")?; 448 let applied_at_unix_s = nonnegative_u64(&row, "applied_at_unix_s")?; 449 let service_version = bounded_text(&row, "service_version", 1, 128)?; 450 let service_commit = bounded_text(&row, "service_commit", 40, 40)?; 451 let lib_revision = bounded_text(&row, "lib_revision", 40, 40)?; 452 let rust_version = bounded_text(&row, "rust_version", 1, 128)?; 453 let target = bounded_text(&row, "target", 1, 128)?; 454 let feature_profile = bounded_text(&row, "feature_profile", 1, 128)?; 455 let build = MigrationBuildIdentity::new( 456 &*service_version, 457 &*service_commit, 458 &*lib_revision, 459 &*rust_version, 460 &*target, 461 &*feature_profile, 462 config_contract_version, 463 state_contract_version, 464 admin_contract_version, 465 status_contract_version, 466 provider_contract_version, 467 ) 468 .map_err(|_| ConfigOperationError::Binding)?; 469 Ok(HistoryEntry { 470 generation, 471 binding: ConfigBinding { 472 normalized_config_sha256, 473 evidence_policy_sha256, 474 service_public_key, 475 config_contract_version, 476 state_contract_version, 477 admin_contract_version, 478 status_contract_version, 479 provider_contract_version, 480 }, 481 applied_at_unix_s, 482 build, 483 }) 484 } 485 486 fn validate_history(history: &[HistoryEntry]) -> Result<(), ConfigOperationError> { 487 let mut previous_time = 0; 488 for (index, entry) in history.iter().enumerate() { 489 if usize::from(entry.generation) != index + 1 490 || entry.applied_at_unix_s < previous_time 491 || !entry.binding.is_governed() 492 || entry.build.config_contract_version() != entry.binding.config_contract_version 493 || entry.build.state_contract_version() != entry.binding.state_contract_version 494 || entry.build.admin_contract_version() != entry.binding.admin_contract_version 495 || entry.build.status_contract_version() != entry.binding.status_contract_version 496 || entry.build.provider_contract_version() != entry.binding.provider_contract_version 497 { 498 return Err(ConfigOperationError::Binding); 499 } 500 previous_time = entry.applied_at_unix_s; 501 } 502 Ok(()) 503 } 504 505 async fn insert_binding( 506 transaction: &mut ServiceSqliteTransaction<'_>, 507 generation: u16, 508 binding: &ConfigBinding, 509 applied_at_unix_s: u64, 510 build: &MigrationBuildIdentity, 511 ) -> Result<(), ConfigOperationError> { 512 sqlx::query(INSERT_BINDING_SQL) 513 .bind(i64::from(generation)) 514 .bind(binding.normalized_config_sha256.as_slice()) 515 .bind(binding.evidence_policy_sha256.as_slice()) 516 .bind(binding.service_public_key.as_ref()) 517 .bind(i64::from(binding.config_contract_version)) 518 .bind(i64::from(binding.state_contract_version)) 519 .bind(i64::from(binding.admin_contract_version)) 520 .bind(i64::from(binding.status_contract_version)) 521 .bind(i64::from(binding.provider_contract_version)) 522 .bind(i64::try_from(applied_at_unix_s).map_err(|_| ConfigOperationError::InvalidInput)?) 523 .bind(build.service_version()) 524 .bind(build.service_commit()) 525 .bind(build.lib_revision()) 526 .bind(build.rust_version()) 527 .bind(build.target()) 528 .bind(build.feature_profile()) 529 .execute(&mut *transaction) 530 .await 531 .map_err(|_| ConfigOperationError::Storage)?; 532 Ok(()) 533 } 534 535 fn exact_digest( 536 row: &sqlx::sqlite::SqliteRow, 537 field: &str, 538 ) -> Result<[u8; 32], ConfigOperationError> { 539 let value = row 540 .try_get::<Vec<u8>, _>(field) 541 .map_err(|_| ConfigOperationError::Binding)?; 542 value.try_into().map_err(|_| ConfigOperationError::Binding) 543 } 544 545 fn bounded_text( 546 row: &sqlx::sqlite::SqliteRow, 547 field: &str, 548 minimum: usize, 549 maximum: usize, 550 ) -> Result<Box<str>, ConfigOperationError> { 551 let length_field = format!("{field}_bytes"); 552 let length = row 553 .try_get::<i64, _>(length_field.as_str()) 554 .ok() 555 .and_then(|value| usize::try_from(value).ok()) 556 .filter(|value| (minimum..=maximum).contains(value)) 557 .ok_or(ConfigOperationError::Binding)?; 558 let value = row 559 .try_get::<String, _>(field) 560 .map_err(|_| ConfigOperationError::Binding)?; 561 if value.len() != length { 562 return Err(ConfigOperationError::Binding); 563 } 564 Ok(value.into_boxed_str()) 565 } 566 567 fn bounded_u16( 568 row: &sqlx::sqlite::SqliteRow, 569 field: &str, 570 minimum: u16, 571 maximum: u16, 572 ) -> Result<u16, ConfigOperationError> { 573 row.try_get::<i64, _>(field) 574 .ok() 575 .and_then(|value| u16::try_from(value).ok()) 576 .filter(|value| (minimum..=maximum).contains(value)) 577 .ok_or(ConfigOperationError::Binding) 578 } 579 580 fn positive_u32(row: &sqlx::sqlite::SqliteRow, field: &str) -> Result<u32, ConfigOperationError> { 581 row.try_get::<i64, _>(field) 582 .ok() 583 .and_then(|value| u32::try_from(value).ok()) 584 .filter(|value| *value != 0) 585 .ok_or(ConfigOperationError::Binding) 586 } 587 588 fn nonnegative_u64( 589 row: &sqlx::sqlite::SqliteRow, 590 field: &str, 591 ) -> Result<u64, ConfigOperationError> { 592 row.try_get::<i64, _>(field) 593 .ok() 594 .and_then(|value| u64::try_from(value).ok()) 595 .ok_or(ConfigOperationError::Binding) 596 } 597 598 fn map_transaction_error( 599 error: ServiceSqliteTransactionError<ConfigOperationError>, 600 ) -> RhiConfigApplyError { 601 if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown { 602 return RhiConfigApplyError::new(RhiConfigApplyErrorKind::CommitOutcomeUnknown); 603 } 604 let kind = match error.operation_error().copied() { 605 Some(ConfigOperationError::InvalidInput) => RhiConfigApplyErrorKind::InvalidInput, 606 Some(ConfigOperationError::Binding) => RhiConfigApplyErrorKind::Binding, 607 Some(ConfigOperationError::ResourceExhausted) => RhiConfigApplyErrorKind::ResourceExhausted, 608 Some(ConfigOperationError::Storage) | None => RhiConfigApplyErrorKind::Transaction, 609 }; 610 RhiConfigApplyError::new(kind) 611 } 612 613 pub(crate) fn metadata_for_configuration( 614 runtime: &crate::RhiRuntimeContext, 615 configuration: &RhiConfigDocumentV1, 616 actual: &radroots_service_sqlite::ServiceDatabaseMetadata, 617 ) -> Result<RhiStateMetadata, RhiConfigApplyError> { 618 RhiStateMetadata::from_existing_database(runtime, configuration, actual) 619 .map_err(|_| RhiConfigApplyError::new(RhiConfigApplyErrorKind::InvalidInput)) 620 }