rhi

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

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 }